feat: unified document inbox, full BAS 2026, and document-transaction matching
- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026) with K2 exclusion flags, per-class data files, and computed SRU codes - Evolve invoice inbox into unified document inbox handling invoices, receipts, and government letters with AI-powered classification (Claude Haiku Vision) - Add multi-pass document-to-transaction matching engine with greedy assignment for both supplier invoices (reference/amount/date/name) and receipts (weighted amount/merchant/date scoring) - Add supplier invoice matching in transaction ingest pipeline - Inject booking template suggestions into AI extraction prompts - Surface matched documents in swipe categorization UI with one-tap booking - Auto-activate missing BAS accounts during SIE import against full reference - Add K2 filter toggle in Chart of Accounts manager - Add receipt confirmation route with BFNAR representation fields - Add database migrations for K2 support and document matching columns - Remove obsolete extension migration scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6956a757f3
commit
39e407644d
@@ -23,7 +23,7 @@ import DescribeTransactionDialog from '@/components/transactions/DescribeTransac
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
|
||||
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem } from '@/types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
export default function TransactionsPage() {
|
||||
@@ -114,9 +114,32 @@ export default function TransactionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch matched inbox items for unbooked transactions
|
||||
const unbookedTxIds = (txData || [])
|
||||
.filter((t) => !t.journal_entry_id && t.is_business === null)
|
||||
.map((t) => t.id)
|
||||
|
||||
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
|
||||
if (unbookedTxIds.length > 0) {
|
||||
const { data: inboxItems } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.in('matched_transaction_id', unbookedTxIds)
|
||||
.in('status', ['ready', 'processing'])
|
||||
if (inboxItems) {
|
||||
inboxItemMap = inboxItems.reduce((acc, item) => {
|
||||
if (item.matched_transaction_id) {
|
||||
acc[item.matched_transaction_id] = item as InvoiceInboxItem
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, InvoiceInboxItem>)
|
||||
}
|
||||
}
|
||||
|
||||
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
|
||||
...t,
|
||||
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
|
||||
matched_inbox_item: inboxItemMap[t.id] || undefined,
|
||||
}))
|
||||
|
||||
setTransactions(transactionsWithInvoices)
|
||||
@@ -176,7 +199,7 @@ export default function TransactionsPage() {
|
||||
}
|
||||
}, [transactions.length])
|
||||
|
||||
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride) => {
|
||||
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
|
||||
try {
|
||||
setProcessingId(id)
|
||||
const response = await fetch(`/api/transactions/${id}/categorize`, {
|
||||
@@ -187,6 +210,8 @@ export default function TransactionsPage() {
|
||||
category,
|
||||
vat_treatment: vatTreatment,
|
||||
account_override: accountOverride,
|
||||
template_id: templateId,
|
||||
inbox_item_id: inboxItemId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -446,6 +471,7 @@ export default function TransactionsPage() {
|
||||
|
||||
async function openSwipeView() {
|
||||
try {
|
||||
// Match invoices to transactions
|
||||
await fetch('/api/transactions/batch-match-invoices', { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
@@ -454,6 +480,16 @@ export default function TransactionsPage() {
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
try {
|
||||
// Run document matching sweep for latest inbox matches
|
||||
await fetch('/api/documents/match-sweep', { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.data?.matched > 0) fetchTransactions()
|
||||
})
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
const uncatIds = uncategorizedTransactions.map((t) => t.id)
|
||||
await fetchCategorySuggestions(uncatIds)
|
||||
setShowSwipeView(true)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Optional: pass specific inbox item IDs to match
|
||||
let inboxItemIds: string[] | undefined
|
||||
try {
|
||||
const body = await request.json()
|
||||
if (Array.isArray(body?.inboxItemIds)) {
|
||||
inboxItemIds = body.inboxItemIds
|
||||
}
|
||||
} catch {
|
||||
// No body or invalid JSON — sweep all unmatched items
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runDocumentMatchingSweep(supabase, user.id, inboxItemIds)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
console.error('[match-sweep] Failed:', error)
|
||||
return NextResponse.json({ error: 'Match sweep failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Fetch inbox item
|
||||
const { data: inboxItem, error: findError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (findError || !inboxItem) {
|
||||
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (inboxItem.document_type !== 'receipt') {
|
||||
return NextResponse.json({ error: 'Inbox item is not a receipt' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!inboxItem.linked_receipt_id) {
|
||||
return NextResponse.json({ error: 'No linked receipt found' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
line_items,
|
||||
matched_transaction_id,
|
||||
representation_persons,
|
||||
representation_purpose,
|
||||
representation_business_connection,
|
||||
} = body
|
||||
|
||||
// Update receipt line items (business/private classification)
|
||||
if (Array.isArray(line_items)) {
|
||||
for (const item of line_items) {
|
||||
if (!item.id) continue
|
||||
await supabase
|
||||
.from('receipt_line_items')
|
||||
.update({
|
||||
is_business: item.is_business,
|
||||
...(item.category ? { category: item.category } : {}),
|
||||
...(item.bas_account ? { bas_account: item.bas_account } : {}),
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('receipt_id', inboxItem.linked_receipt_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate business/private totals
|
||||
const { data: updatedLineItems } = await supabase
|
||||
.from('receipt_line_items')
|
||||
.select('*')
|
||||
.eq('receipt_id', inboxItem.linked_receipt_id)
|
||||
|
||||
let businessTotal = 0
|
||||
let privateTotal = 0
|
||||
if (updatedLineItems) {
|
||||
for (const li of updatedLineItems) {
|
||||
if (li.is_business === true) {
|
||||
businessTotal += li.line_total
|
||||
} else if (li.is_business === false) {
|
||||
privateTotal += li.line_total
|
||||
}
|
||||
}
|
||||
}
|
||||
businessTotal = Math.round(businessTotal * 100) / 100
|
||||
privateTotal = Math.round(privateTotal * 100) / 100
|
||||
|
||||
// Update receipt with match and representation data
|
||||
const receiptUpdate: Record<string, unknown> = {
|
||||
status: 'confirmed',
|
||||
}
|
||||
|
||||
if (matched_transaction_id) {
|
||||
receiptUpdate.matched_transaction_id = matched_transaction_id
|
||||
}
|
||||
if (representation_persons != null) {
|
||||
receiptUpdate.representation_persons = representation_persons
|
||||
}
|
||||
if (representation_purpose) {
|
||||
receiptUpdate.representation_purpose = representation_purpose
|
||||
}
|
||||
if (representation_business_connection) {
|
||||
receiptUpdate.representation_business_connection = representation_business_connection
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('receipts')
|
||||
.update(receiptUpdate)
|
||||
.eq('id', inboxItem.linked_receipt_id)
|
||||
|
||||
// Link transaction to receipt if provided
|
||||
if (matched_transaction_id) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: inboxItem.linked_receipt_id })
|
||||
.eq('id', matched_transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
}
|
||||
|
||||
// Update inbox item status
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'confirmed' })
|
||||
.eq('id', id)
|
||||
|
||||
// Emit event (non-blocking)
|
||||
try {
|
||||
const { data: receipt } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', inboxItem.linked_receipt_id)
|
||||
.single()
|
||||
|
||||
if (receipt) {
|
||||
await eventBus.emit({
|
||||
type: 'receipt.confirmed',
|
||||
payload: {
|
||||
receipt,
|
||||
businessTotal,
|
||||
privateTotal,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-blocking
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { confirmed: true, businessTotal, privateTotal } })
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export async function POST(
|
||||
|
||||
if (!supplierId) {
|
||||
// Create new supplier from extracted data
|
||||
const supplierName = extraction.supplier.name
|
||||
const supplierName = extraction.supplier?.name
|
||||
if (!supplierName) {
|
||||
return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 })
|
||||
}
|
||||
@@ -60,13 +60,13 @@ export async function POST(
|
||||
user_id: user.id,
|
||||
name: supplierName,
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: extraction.supplier.orgNumber || null,
|
||||
vat_number: extraction.supplier.vatNumber || null,
|
||||
bankgiro: extraction.supplier.bankgiro || null,
|
||||
plusgiro: extraction.supplier.plusgiro || null,
|
||||
org_number: extraction.supplier?.orgNumber || null,
|
||||
vat_number: extraction.supplier?.vatNumber || null,
|
||||
bankgiro: extraction.supplier?.bankgiro || null,
|
||||
plusgiro: extraction.supplier?.plusgiro || null,
|
||||
default_expense_account: '6200',
|
||||
default_payment_terms: 30,
|
||||
default_currency: extraction.invoice.currency || 'SEK',
|
||||
default_currency: extraction.invoice?.currency || 'SEK',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -99,7 +99,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
// Build line items from extraction
|
||||
const items = extraction.lineItems.map((item, index) => {
|
||||
const items = (extraction.lineItems || []).map((item, index) => {
|
||||
const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25
|
||||
const lineTotal = Math.round(item.lineTotal * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
||||
@@ -118,7 +118,7 @@ export async function POST(
|
||||
})
|
||||
|
||||
// If no line items, create a single item from totals
|
||||
if (items.length === 0 && extraction.totals.total) {
|
||||
if (items.length === 0 && extraction.totals?.total) {
|
||||
const total = extraction.totals.total
|
||||
const vatAmount = extraction.totals.vatAmount || 0
|
||||
const subtotal = extraction.totals.subtotal || total - vatAmount
|
||||
@@ -155,13 +155,13 @@ export async function POST(
|
||||
user_id: user.id,
|
||||
supplier_id: supplierId,
|
||||
arrival_number: arrivalNum,
|
||||
supplier_invoice_number: extraction.invoice.invoiceNumber || `INBOX-${Date.now()}`,
|
||||
invoice_date: extraction.invoice.invoiceDate || new Date().toISOString().split('T')[0],
|
||||
due_date: extraction.invoice.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
|
||||
supplier_invoice_number: extraction.invoice?.invoiceNumber || `INBOX-${Date.now()}`,
|
||||
invoice_date: extraction.invoice?.invoiceDate || new Date().toISOString().split('T')[0],
|
||||
due_date: extraction.invoice?.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
|
||||
status: 'registered',
|
||||
currency: extraction.invoice.currency || 'SEK',
|
||||
currency: extraction.invoice?.currency || 'SEK',
|
||||
vat_treatment: vatTreatment,
|
||||
payment_reference: extraction.invoice.paymentReference || null,
|
||||
payment_reference: extraction.invoice?.paymentReference || null,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
total: Math.round(total * 100) / 100,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { eventBus } from '@/lib/events/bus'
|
||||
import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer'
|
||||
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
|
||||
import { getSettings } from '@/extensions/general/invoice-inbox'
|
||||
import { matchDocumentToTransactions } from '@/lib/documents/document-matcher'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -87,16 +89,27 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Update inbox item
|
||||
// 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
|
||||
}
|
||||
|
||||
const { data: updatedItem, error: updateError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
error_message: null,
|
||||
})
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
@@ -114,6 +127,28 @@ export async function POST(
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
// Document-to-transaction matching (non-blocking)
|
||||
try {
|
||||
const matchResult = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
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 })
|
||||
|
||||
@@ -5,6 +5,8 @@ import { eventBus } from '@/lib/events/bus'
|
||||
import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer'
|
||||
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
|
||||
import { getSettings } from '@/extensions/general/invoice-inbox'
|
||||
import { matchDocumentToTransactions } from '@/lib/documents/document-matcher'
|
||||
import type { InvoiceInboxItem, InvoiceExtractionResult } from '@/types'
|
||||
import crypto from 'crypto'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -19,16 +21,21 @@ export async function GET(request: Request) {
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
const documentType = searchParams.get('document_type')
|
||||
|
||||
let query = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name)')
|
||||
.select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name), receipt:receipts(id, merchant_name, total_amount, receipt_date, status, matched_transaction_id)')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (status && status !== 'all') {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (documentType && documentType !== 'all') {
|
||||
query = query.eq('document_type', documentType)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
@@ -47,85 +54,113 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
// Support batch upload: multiple `files` entries, fallback to single `file`
|
||||
const files: File[] = []
|
||||
const multiFiles = formData.getAll('files')
|
||||
if (multiFiles.length > 0) {
|
||||
for (const f of multiFiles) {
|
||||
if (f instanceof File) files.push(f)
|
||||
}
|
||||
} else {
|
||||
const single = formData.get('file') as File | null
|
||||
if (single) files.push(single)
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supportedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
|
||||
if (!supportedTypes.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 })
|
||||
const items: Array<Record<string, unknown>> = []
|
||||
const errors: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!supportedTypes.includes(file.type)) {
|
||||
errors.push(`${file.name}: unsupported file type`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await uploadAndCreateInboxItem(supabase, user.id, file)
|
||||
items.push(result.inboxItem)
|
||||
|
||||
// Process asynchronously
|
||||
processInboxItem(result.inboxItem.id as string, user.id, result.base64, file.type).catch((err) =>
|
||||
console.error('[invoice-inbox] Background processing failed:', err)
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Upload failed'
|
||||
errors.push(`${file.name}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Read file
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
const base64 = buffer.toString('base64')
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
// Return array for batch, single item for backward compat
|
||||
if (files.length === 1 && items.length === 1) {
|
||||
return NextResponse.json({ data: items[0] })
|
||||
}
|
||||
|
||||
// Upload to storage
|
||||
const storagePath = `documents/${user.id}/inbox/${Date.now()}-${file.name}`
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, buffer, { contentType: file.type })
|
||||
return NextResponse.json({ data: items, errors: errors.length > 0 ? errors : undefined })
|
||||
}
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
|
||||
}
|
||||
async function uploadAndCreateInboxItem(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
file: File
|
||||
): Promise<{ inboxItem: Record<string, unknown>; base64: string }> {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
const base64 = buffer.toString('base64')
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
// Create document attachment record
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
storage_path: storagePath,
|
||||
file_name: file.name,
|
||||
file_size_bytes: buffer.length,
|
||||
mime_type: file.type,
|
||||
sha256_hash: hash,
|
||||
upload_source: 'file_upload',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
const storagePath = `documents/${userId}/inbox/${Date.now()}-${file.name}`
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, buffer, { contentType: file.type })
|
||||
|
||||
if (docError || !document) {
|
||||
return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 })
|
||||
}
|
||||
if (uploadError) {
|
||||
throw new Error('Failed to upload file')
|
||||
}
|
||||
|
||||
// Create inbox item
|
||||
const { data: inboxItem, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
status: 'processing',
|
||||
source: 'upload',
|
||||
document_id: document.id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (itemError || !inboxItem) {
|
||||
return NextResponse.json({ error: 'Failed to create inbox item' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Emit received event
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.received',
|
||||
payload: { inboxItem, userId: user.id },
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
storage_path: storagePath,
|
||||
file_name: file.name,
|
||||
file_size_bytes: buffer.length,
|
||||
mime_type: file.type,
|
||||
sha256_hash: hash,
|
||||
upload_source: 'file_upload',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Process asynchronously - analyze and match
|
||||
processInboxItem(inboxItem.id, user.id, base64, file.type).catch((err) =>
|
||||
console.error('[invoice-inbox] Background processing failed:', err)
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: inboxItem })
|
||||
} catch (error) {
|
||||
console.error('[invoice-inbox] Upload failed:', error)
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
|
||||
if (docError || !document) {
|
||||
throw new Error('Failed to create document record')
|
||||
}
|
||||
|
||||
const { data: inboxItem, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
status: 'processing',
|
||||
source: 'upload',
|
||||
document_id: document.id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (itemError || !inboxItem) {
|
||||
throw new Error('Failed to create inbox item')
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.received',
|
||||
payload: { inboxItem, userId },
|
||||
})
|
||||
|
||||
return { inboxItem, base64 }
|
||||
}
|
||||
|
||||
async function processInboxItem(
|
||||
@@ -137,8 +172,19 @@ async function processInboxItem(
|
||||
const supabase = await createClient()
|
||||
|
||||
try {
|
||||
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI extraction (${mimeType})`)
|
||||
const extraction = await analyzeInvoice(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,
|
||||
})
|
||||
|
||||
// Supplier matching
|
||||
const settings = await getSettings(userId)
|
||||
let matchedSupplierId: string | null = null
|
||||
@@ -153,20 +199,31 @@ async function processInboxItem(
|
||||
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({
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.update(updateData)
|
||||
.eq('id', itemId)
|
||||
|
||||
// Fetch the updated item for event emission and matching
|
||||
const { data: updatedItem } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
@@ -182,6 +239,29 @@ async function processInboxItem(
|
||||
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)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Webhook } from 'svix'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer'
|
||||
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
|
||||
import { classifyDocument } from '@/lib/documents/classifier'
|
||||
import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline'
|
||||
import crypto from 'crypto'
|
||||
|
||||
function createServiceClient() {
|
||||
@@ -19,11 +21,31 @@ function createServiceClient() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build raw email payload for BFL 7 kap. 2§ archiving.
|
||||
* Includes full email headers and body — excludes binary attachment content.
|
||||
*/
|
||||
function buildRawEmailPayload(body: Record<string, unknown>, payload: { from: string; to: string; subject: string; created_at: string }): Record<string, unknown> {
|
||||
return {
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
created_at: payload.created_at,
|
||||
text: body.text ?? null,
|
||||
html: body.html ?? null,
|
||||
headers: body.headers ?? null,
|
||||
message_id: body.message_id ?? null,
|
||||
in_reply_to: body.in_reply_to ?? null,
|
||||
references: body.references ?? null,
|
||||
archived_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify webhook signature
|
||||
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
|
||||
if (!webhookSecret) {
|
||||
console.error('[invoice-inbox] RESEND_WEBHOOK_SECRET not configured')
|
||||
console.error('[document-inbox] RESEND_WEBHOOK_SECRET not configured')
|
||||
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -61,15 +83,17 @@ export async function POST(request: Request) {
|
||||
const userId = await resolveUserFromEmail(payload.to, supabase)
|
||||
|
||||
if (!userId) {
|
||||
console.warn(`[invoice-inbox] No user found for email: ${payload.to}`)
|
||||
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
|
||||
const rawEmailPayload = buildRawEmailPayload(body, payload)
|
||||
|
||||
// Extract file attachments
|
||||
const attachments = extractAttachments(payload)
|
||||
|
||||
if (attachments.length === 0) {
|
||||
// Create inbox item with error status (no attachments)
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
@@ -80,6 +104,7 @@ export async function POST(request: Request) {
|
||||
email_subject: payload.subject,
|
||||
email_received_at: payload.created_at,
|
||||
error_message: 'No supported attachments found',
|
||||
raw_email_payload: rawEmailPayload,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { processed: 0, message: 'No attachments' } })
|
||||
@@ -99,7 +124,7 @@ export async function POST(request: Request) {
|
||||
.upload(storagePath, buffer, { contentType: attachment.content_type })
|
||||
|
||||
if (uploadError) {
|
||||
console.error('[invoice-inbox] Upload failed:', uploadError)
|
||||
console.error('[document-inbox] Upload failed:', uploadError)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -120,7 +145,19 @@ export async function POST(request: Request) {
|
||||
|
||||
if (docError || !document) continue
|
||||
|
||||
// Create inbox item
|
||||
// Classify document type
|
||||
let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice'
|
||||
let isReverseCharge = false
|
||||
try {
|
||||
const classification = await classifyDocument(attachment.content, attachment.content_type)
|
||||
documentType = classification.type
|
||||
isReverseCharge = classification.isReverseCharge ?? false
|
||||
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${classification.confidence})`)
|
||||
} catch (classifyErr) {
|
||||
console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr)
|
||||
}
|
||||
|
||||
// Create inbox item with document type and raw email payload
|
||||
const { data: inboxItem, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
@@ -131,41 +168,103 @@ export async function POST(request: Request) {
|
||||
email_subject: payload.subject,
|
||||
email_received_at: payload.created_at,
|
||||
document_id: document.id,
|
||||
document_type: documentType,
|
||||
raw_email_payload: rawEmailPayload,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (itemError || !inboxItem) continue
|
||||
|
||||
// Process: analyze invoice
|
||||
// Route based on document type
|
||||
try {
|
||||
const extraction = await analyzeInvoice(attachment.content, attachment.content_type)
|
||||
switch (documentType) {
|
||||
case 'supplier_invoice': {
|
||||
// Existing flow: analyze invoice + supplier match
|
||||
const extraction = await analyzeInvoice(attachment.content, attachment.content_type)
|
||||
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
// Store reverse charge flag from classifier in extracted data
|
||||
const extractedData = {
|
||||
...(extraction as unknown as Record<string, unknown>),
|
||||
isReverseCharge,
|
||||
}
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= 0.7) {
|
||||
matchedSupplierId = match.supplierId
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
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 >= 0.7) {
|
||||
matchedSupplierId = match.supplierId
|
||||
}
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: extractedData,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'receipt': {
|
||||
// Receipt pipeline: extract + categorize + match transactions
|
||||
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
|
||||
|
||||
const result = await processReceiptFromDocument(supabase, userId, attachment.content, attachment.content_type, {
|
||||
documentId: document.id,
|
||||
source: 'email',
|
||||
emailFrom: payload.from,
|
||||
storageUrl: urlData.publicUrl,
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
linked_receipt_id: result.receipt.id,
|
||||
confidence: result.receipt.extraction_confidence,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'government_letter': {
|
||||
// Store with status ready for manual review
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: {
|
||||
sender: payload.from,
|
||||
subject: payload.subject,
|
||||
body: typeof body.text === 'string' ? body.text : null,
|
||||
},
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'unknown':
|
||||
default: {
|
||||
// Store with status ready for manual handling
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'ready' })
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Analysis failed'
|
||||
const message = err instanceof Error ? err.message : 'Processing failed'
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'error', error_message: message })
|
||||
@@ -174,7 +273,7 @@ export async function POST(request: Request) {
|
||||
|
||||
processed.push(inboxItem.id)
|
||||
} catch (err) {
|
||||
console.error('[invoice-inbox] Processing attachment failed:', err)
|
||||
console.error('[document-inbox] Processing attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'
|
||||
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
||||
import { suggestMappings } from '@/lib/import/account-mapper'
|
||||
import { executeSIEImport } from '@/lib/import/sie-import'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
@@ -54,24 +56,7 @@ export async function POST(request: Request) {
|
||||
if (mappingsJson) {
|
||||
mappings = JSON.parse(mappingsJson)
|
||||
} else {
|
||||
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
|
||||
const basAccounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Load stored mappings
|
||||
// Match against full BAS reference (not just user's active chart)
|
||||
const { data: storedMappings } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('*')
|
||||
@@ -79,7 +64,7 @@ export async function POST(request: Request) {
|
||||
|
||||
mappings = suggestMappings(
|
||||
parsed.accounts,
|
||||
basAccounts,
|
||||
BAS_REFERENCE,
|
||||
(storedMappings as SIEAccountMappingRecord[]) || undefined
|
||||
)
|
||||
}
|
||||
@@ -97,6 +82,94 @@ export async function POST(request: Request) {
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Auto-activate any mapped BAS accounts not yet in the user's chart
|
||||
const mappedAccountNumbers = [
|
||||
...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)),
|
||||
]
|
||||
|
||||
const existingAccounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('user_id', user.id)
|
||||
.in('account_number', mappedAccountNumbers)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Build a lookup from SIE mappings for account names (used for bas_range accounts)
|
||||
const mappingNameLookup = new Map<string, string>()
|
||||
for (const m of mappings) {
|
||||
if (m.targetAccount) {
|
||||
mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName)
|
||||
}
|
||||
}
|
||||
|
||||
const existingNumbers = new Set(existingAccounts.map((a) => a.account_number))
|
||||
const accountsToActivate = mappedAccountNumbers
|
||||
.filter((num) => !existingNumbers.has(num))
|
||||
.map((num) => {
|
||||
const ref = getBASReference(num)
|
||||
if (ref) {
|
||||
// Account exists in BAS reference — use full metadata
|
||||
return {
|
||||
user_id: user.id,
|
||||
account_number: ref.account_number,
|
||||
account_name: ref.account_name,
|
||||
account_class: ref.account_class,
|
||||
account_group: ref.account_group,
|
||||
account_type: ref.account_type,
|
||||
normal_balance: ref.normal_balance,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
description: ref.description,
|
||||
sru_code: ref.sru_code,
|
||||
sort_order: parseInt(ref.account_number),
|
||||
}
|
||||
}
|
||||
|
||||
// Account not in BAS reference (sub-account like 1241 Personbilar).
|
||||
// Derive metadata from the account number.
|
||||
const accountClass = parseInt(num.charAt(0), 10)
|
||||
const accountGroup = num.substring(0, 2)
|
||||
const accountName = mappingNameLookup.get(num) || `Konto ${num}`
|
||||
const accountType =
|
||||
accountClass === 1 ? 'asset'
|
||||
: accountClass === 2 ? 'liability'
|
||||
: accountClass === 3 ? 'revenue'
|
||||
: 'expense'
|
||||
const normalBalance =
|
||||
accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit'
|
||||
|
||||
return {
|
||||
user_id: user.id,
|
||||
account_number: num,
|
||||
account_name: accountName,
|
||||
account_class: accountClass,
|
||||
account_group: accountGroup,
|
||||
account_type: accountType,
|
||||
normal_balance: normalBalance,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
description: accountName,
|
||||
sru_code: null,
|
||||
sort_order: parseInt(num),
|
||||
}
|
||||
})
|
||||
|
||||
if (accountsToActivate.length > 0) {
|
||||
const { error: activateError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(accountsToActivate)
|
||||
|
||||
if (activateError) {
|
||||
return NextResponse.json({
|
||||
error: `Failed to activate accounts: ${activateError.message}`,
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the import
|
||||
const result = await executeSIEImport(
|
||||
user.id,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
parseSIEFile,
|
||||
@@ -10,7 +9,8 @@ import {
|
||||
} from '@/lib/import/sie-parser'
|
||||
import { suggestMappings, getMappingStats } from '@/lib/import/account-mapper'
|
||||
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
|
||||
import type { SIEAccountMappingRecord, SIEAccount } from '@/lib/import/types'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
||||
import type { SIEAccountMappingRecord } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/parse
|
||||
@@ -78,33 +78,18 @@ export async function POST(request: Request) {
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
|
||||
const basAccounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch stored mappings from database
|
||||
const { data: storedMappings } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
// Suggest account mappings
|
||||
// Match against the full BAS reference (1,276 accounts) instead of only
|
||||
// the user's active chart (~40 accounts). Accounts that match will be
|
||||
// auto-activated during the execute step.
|
||||
const mappings = suggestMappings(
|
||||
parsed.accounts,
|
||||
basAccounts,
|
||||
BAS_REFERENCE,
|
||||
(storedMappings as SIEAccountMappingRecord[]) || undefined
|
||||
)
|
||||
|
||||
|
||||
@@ -159,11 +159,17 @@ export async function POST(
|
||||
const template = getTemplateById(body.template_id)
|
||||
if (template) {
|
||||
finalCategory = is_business ? template.fallback_category : 'private'
|
||||
console.log(`[categorize] tx=${id} using template="${body.template_id}" (${template.name_sv}) → category=${finalCategory}, debit=${template.debit_account}, credit=${template.credit_account}, vat=${template.vat_treatment}`)
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 })
|
||||
}
|
||||
} else {
|
||||
finalCategory = is_business ? (category || 'uncategorized') : 'private'
|
||||
console.log(`[categorize] tx=${id} using category="${finalCategory}" vat=${body.vat_treatment || 'default'} account_override=${body.account_override || 'none'}`)
|
||||
}
|
||||
|
||||
if (body.inbox_item_id) {
|
||||
console.log(`[categorize] tx=${id} will confirm inbox item=${body.inbox_item_id} and link document`)
|
||||
}
|
||||
|
||||
// Build mapping result from template or category
|
||||
@@ -185,6 +191,12 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[categorize] tx=${id} mapping result:`, {
|
||||
debit: mappingResult.debit_account,
|
||||
credit: mappingResult.credit_account,
|
||||
vatLines: mappingResult.vat_lines.map((v) => `${v.account_number} debit=${v.debit_amount} credit=${v.credit_amount}`),
|
||||
})
|
||||
|
||||
// Apply account override if provided (only for business transactions)
|
||||
if (is_business && body.account_override) {
|
||||
// Validate the account exists in the user's chart of accounts
|
||||
@@ -287,6 +299,36 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm matched inbox item and link its document to the journal entry
|
||||
if (body.inbox_item_id) {
|
||||
try {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'confirmed' })
|
||||
.eq('id', body.inbox_item_id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
// Link inbox item's document to the journal entry
|
||||
if (journalEntryId) {
|
||||
const { data: inboxItem } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('document_id')
|
||||
.eq('id', body.inbox_item_id)
|
||||
.single()
|
||||
|
||||
if (inboxItem?.document_id) {
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', inboxItem.document_id)
|
||||
.eq('user_id', user.id)
|
||||
}
|
||||
}
|
||||
} catch (inboxErr) {
|
||||
console.error('[categorize] Failed to update inbox item:', inboxErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the transaction
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
|
||||
@@ -137,6 +137,51 @@ export async function POST(request: Request) {
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
|
||||
}
|
||||
|
||||
// Inject document template suggestions from matched inbox items
|
||||
try {
|
||||
const { data: matchedInboxItems } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('matched_transaction_id, suggested_template_id, suggested_template_confidence')
|
||||
.eq('user_id', user.id)
|
||||
.in('matched_transaction_id', ids)
|
||||
.not('suggested_template_id', 'is', null)
|
||||
|
||||
if (matchedInboxItems && matchedInboxItems.length > 0) {
|
||||
console.log(`[suggest-categories] Found ${matchedInboxItems.length} matched inbox items with template suggestions`)
|
||||
const { getTemplateById } = await import('@/lib/bookkeeping/booking-templates')
|
||||
|
||||
for (const item of matchedInboxItems) {
|
||||
const txId = item.matched_transaction_id as string
|
||||
const templateId = item.suggested_template_id as string
|
||||
const template = getTemplateById(templateId)
|
||||
if (!template) {
|
||||
console.log(`[suggest-categories] Template "${templateId}" not found, skipping`)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`[suggest-categories] Injecting document template: tx=${txId} → ${templateId} (${template.name_sv}, debit=${template.debit_account}, confidence=${item.suggested_template_confidence})`)
|
||||
|
||||
// Add to template_suggestions at the top with boosted confidence
|
||||
const existing = template_suggestions[txId] || []
|
||||
const docTemplate: SuggestedTemplate = {
|
||||
template_id: templateId,
|
||||
name_sv: template.name_sv,
|
||||
name_en: template.name_en,
|
||||
group: template.group,
|
||||
debit_account: template.debit_account,
|
||||
credit_account: template.credit_account,
|
||||
confidence: Math.min((item.suggested_template_confidence as number) || 0.8, 1),
|
||||
description_sv: template.description_sv,
|
||||
risk_level: template.risk_level,
|
||||
requires_review: template.requires_review,
|
||||
}
|
||||
template_suggestions[txId] = [docTemplate, ...existing.filter((t) => t.template_id !== templateId)]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-blocking
|
||||
}
|
||||
|
||||
// Trigger on-demand AI categorization for transactions with weak suggestions
|
||||
if (needsAiIds.length > 0) {
|
||||
console.log(
|
||||
|
||||
@@ -56,6 +56,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
equity: 'EK',
|
||||
revenue: 'Intakt',
|
||||
expense: 'Kostnad',
|
||||
untaxed_reserves: 'Ob. reserver',
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -69,6 +70,7 @@ export default function ChartOfAccountsManager() {
|
||||
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
|
||||
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
|
||||
|
||||
// Data state
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
@@ -104,10 +106,25 @@ export default function ChartOfAccountsManager() {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchAccounts(), fetchReference()])
|
||||
// Set K2 filter default based on company settings (plan_type)
|
||||
if (hideK2Excluded === null) {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
|
||||
setHideK2Excluded(data?.plan_type === 'k1')
|
||||
} else {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
} catch {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [fetchAccounts, fetchReference])
|
||||
}, [fetchAccounts, fetchReference, hideK2Excluded])
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([fetchAccounts(), fetchReference()])
|
||||
@@ -221,12 +238,18 @@ export default function ChartOfAccountsManager() {
|
||||
}, [filteredAccounts])
|
||||
|
||||
const filteredReference = useMemo(() => {
|
||||
if (!searchQuery) return referenceAccounts
|
||||
const q = searchQuery.toLowerCase()
|
||||
return referenceAccounts.filter(
|
||||
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
|
||||
)
|
||||
}, [referenceAccounts, searchQuery])
|
||||
let filtered = referenceAccounts
|
||||
if (hideK2Excluded) {
|
||||
filtered = filtered.filter((a) => !a.k2_excluded)
|
||||
}
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return filtered
|
||||
}, [referenceAccounts, searchQuery, hideK2Excluded])
|
||||
|
||||
const groupedReference = useMemo(() => {
|
||||
const grouped: Record<number, ReferenceAccount[]> = {}
|
||||
@@ -284,6 +307,17 @@ export default function ChartOfAccountsManager() {
|
||||
Eget konto
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{view === 'bas-catalog' && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={hideK2Excluded ?? false}
|
||||
onCheckedChange={setHideK2Excluded}
|
||||
className="scale-75"
|
||||
/>
|
||||
<span className="text-muted-foreground">Dolj K2-undantagna</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceInboxItem, Supplier, DocumentClassificationType } from '@/types'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Settings, Inbox, CheckCircle2, AlertTriangle, Receipt, FileText, RefreshCw } from 'lucide-react'
|
||||
import DocumentInboxCard from '@/components/extensions/general/document-inbox/DocumentInboxCard'
|
||||
import ReceiptInboxDetail from '@/components/extensions/general/document-inbox/ReceiptInboxDetail'
|
||||
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
|
||||
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
|
||||
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
|
||||
|
||||
type TabValue = 'all' | DocumentClassificationType
|
||||
|
||||
const TABS: { value: TabValue; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla' },
|
||||
{ value: 'supplier_invoice', label: 'Fakturor' },
|
||||
{ value: 'receipt', label: 'Kvitton' },
|
||||
{ value: 'government_letter', label: 'Myndighetspost' },
|
||||
{ value: 'unknown', label: 'Övrigt' },
|
||||
]
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [items, setItems] = useState<InvoiceInboxItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('all')
|
||||
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSuppliers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchSettings()
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
|
||||
const newItems = Array.isArray(result) ? result : [result]
|
||||
setItems((prev) => [...newItems, ...prev])
|
||||
for (const item of newItems) {
|
||||
pollItem(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const [isMatching, setIsMatching] = useState(false)
|
||||
|
||||
async function handleMatchSweep() {
|
||||
setIsMatching(true)
|
||||
try {
|
||||
const res = await fetch('/api/documents/match-sweep', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
await fetchItems()
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
setIsMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? data : it))
|
||||
)
|
||||
setSelectedItem((current) =>
|
||||
current?.id === itemId ? data : current
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: InvoiceInboxItem) {
|
||||
setSelectedItem(item)
|
||||
}
|
||||
|
||||
async function handleConfirm(itemId: string, supplierId?: string) {
|
||||
const body: Record<string, string> = {}
|
||||
if (supplierId) body.supplier_id = supplierId
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
fetchSuppliers()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReprocess(itemId: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
|
||||
}
|
||||
} else {
|
||||
fetchItems()
|
||||
}
|
||||
}
|
||||
|
||||
function handleReceiptConfirm() {
|
||||
fetchItems()
|
||||
setSelectedItem(null)
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems =
|
||||
activeTab === 'all'
|
||||
? items
|
||||
: items.filter((it) => (it.document_type ?? 'supplier_invoice') === activeTab)
|
||||
|
||||
const totalPending = items.filter((it) => it.status === 'ready' || it.status === 'pending').length
|
||||
const receiptCount = items.filter((it) => it.document_type === 'receipt' && it.status === 'ready').length
|
||||
const invoiceCount = items.filter((it) => (it.document_type ?? 'supplier_invoice') === 'supplier_invoice' && it.status === 'ready').length
|
||||
|
||||
// Determine which detail dialog to show
|
||||
const isReceiptSelected = selectedItem?.document_type === 'receipt'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Dokumentinkorg"
|
||||
description="Alla inkommande dokument — fakturor, kvitton och myndighetspost"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMatchSweep}
|
||||
disabled={isMatching}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isMatching ? 'animate-spin' : ''}`} />
|
||||
Matcha alla
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{totalPending}</p>
|
||||
<p className="text-xs text-muted-foreground">Att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
|
||||
<FileText className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{invoiceCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Fakturor att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary/50">
|
||||
<Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{receiptCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Kvitton att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<InboxUploadZone
|
||||
onUploadComplete={handleUploadComplete}
|
||||
isUploading={isUploading}
|
||||
setIsUploading={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Tabs by document type */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeTab === 'all'
|
||||
? 'Inga dokument ännu. Ladda upp ett dokument ovan eller skicka via e-post.'
|
||||
: 'Inga dokument av denna typ.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<DocumentInboxCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => handleItemClick(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Receipt detail dialog */}
|
||||
{isReceiptSelected && (
|
||||
<ReceiptInboxDetail
|
||||
item={selectedItem}
|
||||
open={selectedItem != null && isReceiptSelected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleReceiptConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invoice/other detail dialog (existing) */}
|
||||
{!isReceiptSelected && (
|
||||
<InboxDetailDialog
|
||||
item={selectedItem}
|
||||
open={selectedItem != null && !isReceiptSelected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
onReprocess={handleReprocess}
|
||||
suppliers={suppliers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings dialog */}
|
||||
<InboxSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -87,10 +87,12 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(newItem: InvoiceInboxItem) {
|
||||
setItems((prev) => [newItem, ...prev])
|
||||
// Poll for processing completion
|
||||
pollItem(newItem.id)
|
||||
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
|
||||
const newItems = Array.isArray(result) ? result : [result]
|
||||
setItems((prev) => [...newItems, ...prev])
|
||||
for (const item of newItems) {
|
||||
pollItem(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import type { InvoiceInboxItem, DocumentClassificationType } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
getDocumentTypeLabel,
|
||||
getDocumentTypeVariant,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Mail, Upload, FileText, Receipt, Landmark } from 'lucide-react'
|
||||
|
||||
interface DocumentInboxCardProps {
|
||||
item: InvoiceInboxItem
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just nu'
|
||||
if (diffMin < 60) return `${diffMin} min sedan`
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH} tim sedan`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
if (diffD === 1) return 'Igår'
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function getDocumentIcon(type: DocumentClassificationType) {
|
||||
switch (type) {
|
||||
case 'receipt':
|
||||
return <Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
case 'government_letter':
|
||||
return <Landmark className="h-5 w-5 text-muted-foreground" />
|
||||
default:
|
||||
return <FileText className="h-5 w-5 text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number } {
|
||||
const type = item.document_type ?? 'supplier_invoice'
|
||||
|
||||
switch (type) {
|
||||
case 'supplier_invoice': {
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const summary = formatExtractionSummary(extraction)
|
||||
return {
|
||||
label: (item.supplier as { name?: string } | undefined)?.name ?? (summary.supplierName || 'Okänd leverantör'),
|
||||
total: summary.total,
|
||||
}
|
||||
}
|
||||
case 'receipt': {
|
||||
const receipt = item.receipt as { merchant_name?: string; total_amount?: number } | undefined
|
||||
return {
|
||||
label: receipt?.merchant_name ?? 'Okänd handlare',
|
||||
total: receipt?.total_amount ?? 0,
|
||||
}
|
||||
}
|
||||
case 'government_letter': {
|
||||
return {
|
||||
label: item.email_from ?? 'Okänd avsändare',
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return { label: 'Granska manuellt', total: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardProps) {
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const docType = (item.document_type ?? 'supplier_invoice') as DocumentClassificationType
|
||||
const docTypeVariant = getDocumentTypeVariant(docType) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
|
||||
const { label: summaryLabel, total } = getSummaryText(item)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{item.source === 'email' ? (
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{getDocumentIcon(docType)}
|
||||
<span className="text-sm font-medium truncate">{fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`text-sm truncate ${summaryLabel === 'Granska manuellt' ? 'text-muted-foreground/60 italic' : 'text-muted-foreground'}`}>
|
||||
{summaryLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{total > 0 && (
|
||||
<span className="text-sm font-medium">{formatSEK(total)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant={docTypeVariant} className="text-[10px] px-1.5 py-0">
|
||||
{getDocumentTypeLabel(docType)}
|
||||
</Badge>
|
||||
{item.confidence != null && (
|
||||
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
|
||||
{confidence.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { CheckCircle2, Receipt, LinkIcon } from 'lucide-react'
|
||||
|
||||
interface ReceiptLineItem {
|
||||
id: string
|
||||
description: string
|
||||
line_total: number
|
||||
vat_rate: number | null
|
||||
is_business: boolean | null
|
||||
category: string | null
|
||||
bas_account: string | null
|
||||
}
|
||||
|
||||
interface ReceiptInboxDetailProps {
|
||||
item: InvoiceInboxItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: 'SEK',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function ReceiptInboxDetail({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: ReceiptInboxDetailProps) {
|
||||
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [representationPersons, setRepresentationPersons] = useState<number | null>(null)
|
||||
const [representationPurpose, setRepresentationPurpose] = useState('')
|
||||
const [representationBusinessConnection, setRepresentationBusinessConnection] = useState('')
|
||||
|
||||
const receipt = item?.receipt as {
|
||||
id?: string
|
||||
merchant_name?: string
|
||||
total_amount?: number
|
||||
receipt_date?: string
|
||||
status?: string
|
||||
matched_transaction_id?: string
|
||||
} | undefined
|
||||
|
||||
// Fetch line items when dialog opens
|
||||
async function fetchLineItems() {
|
||||
if (!item?.linked_receipt_id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/receipt-ocr/${item.linked_receipt_id}`)
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data?.line_items) {
|
||||
setLineItems(data.line_items)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen && item?.linked_receipt_id) {
|
||||
fetchLineItems()
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
function toggleBusiness(lineItemId: string) {
|
||||
setLineItems((prev) =>
|
||||
prev.map((li) =>
|
||||
li.id === lineItemId
|
||||
? { ...li, is_business: li.is_business === true ? false : true }
|
||||
: li
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!item?.id || !item.linked_receipt_id) return
|
||||
setConfirming(true)
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
line_items: lineItems.map((li) => ({
|
||||
id: li.id,
|
||||
is_business: li.is_business,
|
||||
category: li.category,
|
||||
bas_account: li.bas_account,
|
||||
})),
|
||||
}
|
||||
|
||||
if (receipt?.matched_transaction_id) {
|
||||
body.matched_transaction_id = receipt.matched_transaction_id
|
||||
}
|
||||
if (representationPersons != null && representationPersons > 0) {
|
||||
body.representation_persons = representationPersons
|
||||
}
|
||||
if (representationPurpose) {
|
||||
body.representation_purpose = representationPurpose
|
||||
}
|
||||
if (representationBusinessConnection) {
|
||||
body.representation_business_connection = representationBusinessConnection
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/extensions/invoice-inbox/inbox/${item.id}/confirm-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
onConfirm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const businessTotal = lineItems
|
||||
.filter((li) => li.is_business === true)
|
||||
.reduce((sum, li) => sum + li.line_total, 0)
|
||||
const privateTotal = lineItems
|
||||
.filter((li) => li.is_business === false)
|
||||
.reduce((sum, li) => sum + li.line_total, 0)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" />
|
||||
Kvitto via e-post
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{receipt && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Handlare</span>
|
||||
<p className="font-medium">{receipt.merchant_name ?? 'Okänd'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Datum</span>
|
||||
<p className="font-medium">{receipt.receipt_date ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Totalbelopp</span>
|
||||
<p className="font-medium">
|
||||
{receipt.total_amount ? formatSEK(receipt.total_amount) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Transaktionsmatch</span>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
{receipt.matched_transaction_id ? (
|
||||
<>
|
||||
<LinkIcon className="h-3 w-3 text-green-600" />
|
||||
<span className="text-green-600">Matchad</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Ingen match</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Line items with business/private toggle */}
|
||||
<div className="space-y-2">
|
||||
<Label>Artikelrader</Label>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Laddar...</p>
|
||||
) : lineItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga rader extraherade</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lineItems.map((li) => (
|
||||
<div
|
||||
key={li.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{li.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSEK(li.line_total)}
|
||||
{li.vat_rate != null && ` (${li.vat_rate}% moms)`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">Företag</span>
|
||||
<Switch
|
||||
checked={li.is_business === true}
|
||||
onCheckedChange={() => toggleBusiness(li.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
{lineItems.length > 0 && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<Badge variant="default">Företag: {formatSEK(Math.round(businessTotal * 100) / 100)}</Badge>
|
||||
<Badge variant="secondary">Privat: {formatSEK(Math.round(privateTotal * 100) / 100)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Representation fields */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Representation (vid restaurangkvitto)
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="rep-persons" className="text-sm">
|
||||
Antal personer
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-persons"
|
||||
type="number"
|
||||
min={0}
|
||||
value={representationPersons ?? ''}
|
||||
onChange={(e) =>
|
||||
setRepresentationPersons(
|
||||
e.target.value ? parseInt(e.target.value) : null
|
||||
)
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rep-purpose" className="text-sm">
|
||||
Syfte
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-purpose"
|
||||
value={representationPurpose}
|
||||
onChange={(e) => setRepresentationPurpose(e.target.value)}
|
||||
placeholder="T.ex. kundmöte"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rep-connection" className="text-sm">
|
||||
Affärsmässig koppling (BFNAR)
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-connection"
|
||||
value={representationBusinessConnection}
|
||||
onChange={(e) =>
|
||||
setRepresentationBusinessConnection(e.target.value)
|
||||
}
|
||||
placeholder="T.ex. potentiell kund, pågående projekt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={confirming}>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" />
|
||||
{confirming ? 'Bekräftar...' : 'Bekräfta kvitto'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import { Upload, Loader2, FileUp } from 'lucide-react'
|
||||
import { Upload, Loader2, FileUp, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InboxUploadZoneProps {
|
||||
onUploadComplete: (item: InvoiceInboxItem) => void
|
||||
onUploadComplete: (item: InvoiceInboxItem | InvoiceInboxItem[]) => void
|
||||
isUploading: boolean
|
||||
setIsUploading: (v: boolean) => void
|
||||
}
|
||||
|
||||
interface FileProgress {
|
||||
name: string
|
||||
status: 'pending' | 'uploading' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
@@ -21,27 +27,48 @@ export default function InboxUploadZone({
|
||||
}: InboxUploadZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [fileProgress, setFileProgress] = useState<FileProgress[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const uploadFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setError(null)
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setError('Filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.')
|
||||
return
|
||||
// Validate all files first
|
||||
const validFiles: File[] = []
|
||||
for (const file of files) {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setError(`${file.name}: filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.`)
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
setError(`${file.name}: filen är för stor. Max 10 MB.`)
|
||||
return
|
||||
}
|
||||
validFiles.push(file)
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
setError('Filen är för stor. Max 10 MB.')
|
||||
return
|
||||
}
|
||||
if (validFiles.length === 0) return
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
// Show per-file progress for multi-file uploads
|
||||
if (validFiles.length > 1) {
|
||||
setFileProgress(validFiles.map((f) => ({ name: f.name, status: 'uploading' })))
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
// Single file: use legacy `file` key for backward compat
|
||||
formData.append('file', validFiles[0])
|
||||
} else {
|
||||
// Multiple files: use `files` key
|
||||
for (const file of validFiles) {
|
||||
formData.append('files', file)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox', {
|
||||
method: 'POST',
|
||||
@@ -51,13 +78,43 @@ export default function InboxUploadZone({
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' }))
|
||||
setError(body.error ?? 'Uppladdning misslyckades')
|
||||
if (validFiles.length > 1) {
|
||||
setFileProgress((prev) => prev.map((f) => ({ ...f, status: 'error' as const })))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await res.json()
|
||||
onUploadComplete(data)
|
||||
const body = await res.json()
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
onUploadComplete(body.data)
|
||||
setFileProgress([])
|
||||
} else {
|
||||
// Mark individual files
|
||||
const items: InvoiceInboxItem[] = body.data || []
|
||||
const errors: string[] = body.errors || []
|
||||
|
||||
setFileProgress((prev) =>
|
||||
prev.map((fp, i) => {
|
||||
// Check if this file had an error
|
||||
const errMsg = errors.find((e) => e.startsWith(fp.name))
|
||||
if (errMsg) {
|
||||
return { ...fp, status: 'error' as const, error: errMsg }
|
||||
}
|
||||
return { ...fp, status: 'done' as const }
|
||||
})
|
||||
)
|
||||
|
||||
if (items.length > 0) {
|
||||
onUploadComplete(items)
|
||||
}
|
||||
|
||||
// Clear progress after a delay
|
||||
setTimeout(() => setFileProgress([]), 3000)
|
||||
}
|
||||
} catch {
|
||||
setError('Nätverksfel vid uppladdning')
|
||||
setFileProgress([])
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
@@ -69,10 +126,10 @@ export default function InboxUploadZone({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) uploadFile(file)
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (files.length > 0) uploadFiles(files)
|
||||
},
|
||||
[uploadFile]
|
||||
[uploadFiles]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
@@ -87,12 +144,11 @@ export default function InboxUploadZone({
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) uploadFile(file)
|
||||
// Reset so same file can be re-selected
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length > 0) uploadFiles(files)
|
||||
e.target.value = ''
|
||||
},
|
||||
[uploadFile]
|
||||
[uploadFiles]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -114,6 +170,7 @@ export default function InboxUploadZone({
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
@@ -127,22 +184,40 @@ export default function InboxUploadZone({
|
||||
) : isDragOver ? (
|
||||
<>
|
||||
<FileUp className="h-8 w-8 text-primary mb-2" />
|
||||
<p className="text-sm font-medium text-primary">Släpp filen här</p>
|
||||
<p className="text-sm font-medium text-primary">Släpp filerna här</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 text-muted-foreground/60 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dra och släpp en faktura, eller{' '}
|
||||
<span className="font-medium text-primary">välj fil</span>
|
||||
Dra och släpp fakturor, eller{' '}
|
||||
<span className="font-medium text-primary">välj filer</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
PDF, JPEG, PNG eller WebP (max 10 MB)
|
||||
PDF, JPEG, PNG eller WebP (max 10 MB per fil)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fileProgress.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{fileProgress.map((fp) => (
|
||||
<div key={fp.name} className="flex items-center gap-2 text-sm">
|
||||
{fp.status === 'uploading' && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
{fp.status === 'done' && <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />}
|
||||
{fp.status === 'error' && <AlertCircle className="h-3.5 w-3.5 text-destructive" />}
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
fp.status === 'error' && 'text-destructive'
|
||||
)}>
|
||||
{fp.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-2">{error}</p>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
|
||||
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
@@ -59,6 +60,8 @@ export default function SwipeCategorizationView({
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
const [showDescribeDialog, setShowDescribeDialog] = useState(false)
|
||||
const [showVatDropdown, setShowVatDropdown] = useState(false)
|
||||
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null)
|
||||
const [pendingInboxItemId, setPendingInboxItemId] = useState<string | null>(null)
|
||||
|
||||
// Clear VAT treatment when switching to a liability/equity account (class 2)
|
||||
useEffect(() => {
|
||||
@@ -120,6 +123,23 @@ export default function SwipeCategorizationView({
|
||||
setPendingCategory(category)
|
||||
setAccountOverride(defaultAccount)
|
||||
setVatTreatment(defaultVat ?? 'none')
|
||||
setPendingTemplateId(null)
|
||||
setPendingInboxItemId(null)
|
||||
setShowVatDropdown(false)
|
||||
setShowCategorySelect(false)
|
||||
setShowReviewStep(true)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => {
|
||||
const template = getTemplateById(templateId)
|
||||
if (!template) return
|
||||
|
||||
setPendingCategory(template.fallback_category)
|
||||
setAccountOverride(template.debit_account)
|
||||
setVatTreatment(template.vat_treatment ?? 'none')
|
||||
setPendingTemplateId(templateId)
|
||||
setPendingInboxItemId(inboxItemId ?? null)
|
||||
setShowVatDropdown(false)
|
||||
setShowCategorySelect(false)
|
||||
setShowReviewStep(true)
|
||||
@@ -180,7 +200,9 @@ export default function SwipeCategorizationView({
|
||||
true,
|
||||
pendingCategory,
|
||||
resolvedVat,
|
||||
override
|
||||
override,
|
||||
pendingTemplateId ?? undefined,
|
||||
pendingInboxItemId ?? undefined
|
||||
)
|
||||
if (journalEntryId) {
|
||||
// Link uploaded documents to the journal entry
|
||||
@@ -210,6 +232,8 @@ export default function SwipeCategorizationView({
|
||||
resetUploadState()
|
||||
setShowReviewStep(false)
|
||||
setPendingCategory(null)
|
||||
setPendingTemplateId(null)
|
||||
setPendingInboxItemId(null)
|
||||
moveToNext()
|
||||
} else {
|
||||
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
|
||||
@@ -248,6 +272,8 @@ export default function SwipeCategorizationView({
|
||||
setShowCategorySelect(false)
|
||||
setShowReviewStep(false)
|
||||
setPendingCategory(null)
|
||||
setPendingTemplateId(null)
|
||||
setPendingInboxItemId(null)
|
||||
resetUploadState()
|
||||
moveToNext()
|
||||
}, [moveToNext, resetUploadState])
|
||||
@@ -424,38 +450,51 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document upload */}
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{/* Document upload / pre-attached document */}
|
||||
{pendingInboxItemId && currentTransaction.matched_inbox_item?.document_id ? (
|
||||
<div className="rounded-lg border bg-blue-500/5 border-blue-500/30 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
<Paperclip className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium">Underlag bifogat</span>
|
||||
<Check className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Dokumentet från inkorgen länkas automatiskt till verifikationen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowUploadZone(!showUploadZone)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Underlag</span>
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{showUploadZone ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{showUploadZone && (
|
||||
<div className="px-3 pb-3">
|
||||
<DocumentUploadZone
|
||||
files={uploadedFiles}
|
||||
onFilesChange={setUploadedFiles}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
@@ -603,6 +642,55 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Match from Inbox */}
|
||||
{currentTransaction.matched_inbox_item && (
|
||||
<div className="p-4 rounded-lg border-2 border-blue-500/40 bg-blue-500/5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
|
||||
<Paperclip className="h-5 w-5" />
|
||||
<span className="font-semibold text-sm">
|
||||
{currentTransaction.matched_inbox_item.document_type === 'receipt'
|
||||
? 'Matchat kvitto'
|
||||
: currentTransaction.matched_inbox_item.document_type === 'supplier_invoice'
|
||||
? 'Matchad leverantörsfaktura'
|
||||
: 'Matchat dokument'}
|
||||
</span>
|
||||
</div>
|
||||
{currentTransaction.matched_inbox_item.match_confidence != null && (
|
||||
<Badge variant="outline" className="text-blue-600 border-blue-500">
|
||||
{Math.round(currentTransaction.matched_inbox_item.match_confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{(() => {
|
||||
const ext = currentTransaction.matched_inbox_item.extracted_data as Record<string, unknown> | null
|
||||
if (!ext) return null
|
||||
const supplierName = (ext as { supplier?: { name?: string } })?.supplier?.name
|
||||
const merchantName = (ext as { merchant?: { name?: string } })?.merchant?.name
|
||||
const totals = ext as { totals?: { total?: number } }
|
||||
return (
|
||||
<>
|
||||
{(supplierName || merchantName) && (
|
||||
<p className="font-medium">{supplierName || merchantName}</p>
|
||||
)}
|
||||
{totals?.totals?.total != null && (
|
||||
<p className="text-muted-foreground">
|
||||
{formatCurrency(totals.totals.total)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{currentTransaction.matched_inbox_item.suggested_template_id && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
Mall: {currentTransaction.matched_inbox_item.suggested_template_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
@@ -642,6 +730,23 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document template match — primary action when inbox item has a suggested template */}
|
||||
{currentTransaction.matched_inbox_item?.suggested_template_id && (() => {
|
||||
const tmplId = currentTransaction.matched_inbox_item!.suggested_template_id!
|
||||
const template = getTemplateById(tmplId)
|
||||
if (!template) return null
|
||||
return (
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={() => handleTemplateSelect(tmplId, currentTransaction.matched_inbox_item!.id)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Paperclip className="mr-2 h-4 w-4" />
|
||||
Bokför som {template.name_sv}
|
||||
</Button>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Invoice match button - primary action when there's a match */}
|
||||
{currentTransaction.potential_invoice && onMatchInvoice && (
|
||||
<Button
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText } from 'lucide-react'
|
||||
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText, Paperclip } from 'lucide-react'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
@@ -51,11 +51,13 @@ export default function TransactionInboxCard({
|
||||
const isDisabled = processingId !== null && processingId !== transaction.id
|
||||
const isIncome = transaction.amount > 0
|
||||
const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id
|
||||
const hasSupplierInvoiceMatch = !!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
|
||||
const topSuggestion = suggestions?.[0]
|
||||
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
||||
const showCheckbox = isBatchMode && isUncategorized
|
||||
const hasWeakSuggestions = !topSuggestion || topSuggestion.confidence < 0.55
|
||||
const showTemplateFallback = hasWeakSuggestions && templateSuggestions && templateSuggestions.length > 0
|
||||
const hasDocumentMatch = !!transaction.matched_inbox_item
|
||||
|
||||
function handleSuggestionClick(suggestion: SuggestedCategory) {
|
||||
if (onOpenQuickReview) {
|
||||
@@ -80,7 +82,7 @@ export default function TransactionInboxCard({
|
||||
>
|
||||
<Card
|
||||
className={`transition-colors ${
|
||||
hasInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
|
||||
hasInvoiceMatch || hasSupplierInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
|
||||
} ${isSelected ? 'border-primary bg-primary/[0.02]' : ''} ${
|
||||
isDisabled ? 'opacity-50' : ''
|
||||
}`}
|
||||
@@ -113,7 +115,16 @@ export default function TransactionInboxCard({
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{transaction.description}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
|
||||
{hasDocumentMatch && (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{transaction.matched_inbox_item!.document_type === 'receipt' ? 'Kvitto' :
|
||||
transaction.matched_inbox_item!.document_type === 'supplier_invoice' ? 'Faktura' : 'Dokument'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,6 +161,21 @@ export default function TransactionInboxCard({
|
||||
)}
|
||||
Matcha Faktura {transaction.potential_invoice!.invoice_number}
|
||||
</Button>
|
||||
) : hasSupplierInvoiceMatch ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => onOpenMatchDialog(transaction)}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
Matcha Leverantörsfaktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
|
||||
</Button>
|
||||
) : topSuggestion ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Transaction, TransactionCategory, Invoice, Customer, VatTreatment } from '@/types'
|
||||
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment, InvoiceInboxItem } from '@/types'
|
||||
|
||||
// Shared transaction type with potential invoice data
|
||||
export interface TransactionWithInvoice extends Transaction {
|
||||
potential_invoice?: Invoice & { customer?: Customer }
|
||||
potential_supplier_invoice?: SupplierInvoice
|
||||
matched_inbox_item?: InvoiceInboxItem
|
||||
}
|
||||
|
||||
// Page view modes
|
||||
@@ -16,7 +18,9 @@ export type CategorizeHandler = (
|
||||
isBusiness: boolean,
|
||||
category?: TransactionCategory,
|
||||
vatTreatment?: VatTreatment,
|
||||
accountOverride?: string
|
||||
accountOverride?: string,
|
||||
templateId?: string,
|
||||
inboxItemId?: string
|
||||
) => Promise<string | null>
|
||||
|
||||
export type MatchInvoiceHandler = (
|
||||
|
||||
@@ -15,6 +15,7 @@ const TYPE_COLORS: Record<AccountType, string> = {
|
||||
equity: 'bg-blue-500',
|
||||
revenue: 'bg-purple-500',
|
||||
expense: 'bg-red-500',
|
||||
untaxed_reserves: 'bg-amber-500',
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<AccountType, string> = {
|
||||
@@ -23,6 +24,7 @@ const TYPE_LABELS: Record<AccountType, string> = {
|
||||
equity: 'Eget kapital',
|
||||
revenue: 'Intäkt',
|
||||
expense: 'Kostnad',
|
||||
untaxed_reserves: 'Obeskattade reserver',
|
||||
}
|
||||
|
||||
interface AccountNumberProps {
|
||||
|
||||
@@ -46,10 +46,19 @@ export interface CategorizationContext {
|
||||
recentHistory: { description: string; category: string }[]
|
||||
}
|
||||
|
||||
export interface DocumentEnrichment {
|
||||
type: 'receipt' | 'supplier_invoice'
|
||||
merchantName?: string
|
||||
lineItems?: Array<{ description: string; amount: number; category?: string; accountSuggestion?: string }>
|
||||
vatBreakdown?: Array<{ rate: number; amount: number }>
|
||||
isReverseCharge?: boolean
|
||||
}
|
||||
|
||||
export interface EnrichedCategorizationContext extends CategorizationContext {
|
||||
candidateTemplates: BookingTemplate[]
|
||||
userAccountUsage: AccountUsageEntry[]
|
||||
merchantHistory: MerchantHistoryEntry[]
|
||||
documentData?: DocumentEnrichment
|
||||
}
|
||||
|
||||
export interface CategorizationSuggestion {
|
||||
@@ -223,6 +232,11 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
.join('\n')}`
|
||||
: ''
|
||||
|
||||
// Build document enrichment context (from linked receipt or supplier invoice)
|
||||
const documentContext = enriched?.documentData
|
||||
? buildDocumentContext(enriched.documentData)
|
||||
: ''
|
||||
|
||||
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
|
||||
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
|
||||
|
||||
@@ -243,7 +257,7 @@ MOMSHANTERING:
|
||||
- Intäkter: Normalt 25% moms (utgående moms, MP1)
|
||||
|
||||
${NON_DEDUCTIBLE_RULES}
|
||||
|
||||
${documentContext}
|
||||
REGLER:
|
||||
1. Negativa belopp = utgifter, positiva = intäkter
|
||||
2. VIKTIGT: Dessa transaktioner kommer från företagets bankkonto/kort. Anta ALLTID att de är affärsrelaterade. Klassificera ALDRIG som "private" — det beslutet tar användaren själv.
|
||||
@@ -382,6 +396,42 @@ ${transactionList}`
|
||||
}
|
||||
}
|
||||
|
||||
function buildDocumentContext(doc: DocumentEnrichment): string {
|
||||
const parts: string[] = []
|
||||
|
||||
const typeLabel = doc.type === 'receipt' ? 'KVITTO' : 'LEVERANTÖRSFAKTURA'
|
||||
parts.push(`LÄNKAT DOKUMENT (${typeLabel}):`)
|
||||
|
||||
if (doc.merchantName) {
|
||||
parts.push(`Handlare/leverantör: ${doc.merchantName}`)
|
||||
}
|
||||
|
||||
if (doc.lineItems && doc.lineItems.length > 0) {
|
||||
parts.push('Rader:')
|
||||
for (const item of doc.lineItems) {
|
||||
let line = `- ${item.description}: ${item.amount} kr`
|
||||
if (item.accountSuggestion) line += ` (föreslaget konto: ${item.accountSuggestion})`
|
||||
if (item.category) line += ` [${item.category}]`
|
||||
parts.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.vatBreakdown && doc.vatBreakdown.length > 0) {
|
||||
parts.push('Momsfördelning:')
|
||||
for (const vat of doc.vatBreakdown) {
|
||||
parts.push(`- ${vat.rate}%: ${vat.amount} kr`)
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.isReverseCharge) {
|
||||
parts.push(`VIKTIGT: Omvänd skattskyldighet (reverse charge). Använd dubbelkontering:
|
||||
- Debitera 2645 (beräknad ingående moms) OCH kreditera 2614 (utgående moms, omvänd skattskyldighet)
|
||||
- Mallen "purchase_eu_service_reverse_charge" ska användas om tillgänglig`)
|
||||
}
|
||||
|
||||
return parts.join('\n') + '\n'
|
||||
}
|
||||
|
||||
function isEnrichedContext(
|
||||
ctx: CategorizationContext | EnrichedCategorizationContext
|
||||
): ctx is EnrichedCategorizationContext {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { InvoiceExtractionResult, ExtractedInvoiceLineItem, VatBreakdownItem } from '../types'
|
||||
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
@@ -40,6 +41,8 @@ VIKTIGT:
|
||||
- Belopp ska vara numeriska värden utan valutasymboler
|
||||
- Ange konfidenstal (0.0-1.0) för hela extraheringen`
|
||||
|
||||
const templateSection = buildTemplatePromptSection()
|
||||
|
||||
const userPrompt = `Analysera denna leverantörsfaktura och extrahera strukturerad data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
@@ -67,7 +70,8 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
"unitPrice": 100.00,
|
||||
"lineTotal": 100.00,
|
||||
"vatRate": 25,
|
||||
"accountSuggestion": "BAS-kontonummer som 5410 eller null"
|
||||
"accountSuggestion": "BAS-kontonummer som 5410 eller null",
|
||||
"suggestedTemplateId": "mall-id eller null"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
@@ -82,10 +86,13 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
"amount": 25.00
|
||||
}
|
||||
],
|
||||
"confidence": 0.95
|
||||
"confidence": 0.95,
|
||||
"suggestedTemplateId": "mall-id för hela fakturan eller null"
|
||||
}
|
||||
|
||||
KONTOKATEGORIER (BAS):
|
||||
${templateSection}
|
||||
|
||||
KONTOKATEGORIER (BAS, backup om ingen mall matchar):
|
||||
- 4000-4999: Varuinköp, material
|
||||
- 5010: Lokalhyra
|
||||
- 5410: Förbrukningsinventarier
|
||||
@@ -212,6 +219,7 @@ function validateAndEnhanceResult(raw: unknown): InvoiceExtractionResult {
|
||||
},
|
||||
vatBreakdown: validateVatBreakdown(data.vatBreakdown),
|
||||
confidence: validateNumber(data.confidence) || 0.5,
|
||||
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +236,7 @@ function validateLineItems(data: any): ExtractedInvoiceLineItem[] {
|
||||
lineTotal: validateNumber(item.lineTotal) || 0,
|
||||
vatRate: validateNumber(item.vatRate),
|
||||
accountSuggestion: validateAccountNumber(item.accountSuggestion as string | undefined),
|
||||
suggestedTemplateId: validateString(item.suggestedTemplateId as string | undefined) || undefined,
|
||||
}))
|
||||
.filter((item: ExtractedInvoiceLineItem) => item.lineTotal > 0 || item.description.length > 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock receipt analyzer
|
||||
vi.mock('../receipt-analyzer', () => ({
|
||||
analyzeReceipt: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock receipt categorizer
|
||||
vi.mock('../receipt-categorizer', () => ({
|
||||
processLineItems: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock receipt matcher
|
||||
vi.mock('../receipt-matcher', () => ({
|
||||
autoMatchReceipts: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock event bus
|
||||
vi.mock('@/lib/events/bus', () => ({
|
||||
eventBus: { emit: vi.fn(), clear: vi.fn() },
|
||||
}))
|
||||
|
||||
import { processReceiptFromDocument } from '../receipt-pipeline'
|
||||
import { analyzeReceipt } from '../receipt-analyzer'
|
||||
import { processLineItems } from '../receipt-categorizer'
|
||||
import { autoMatchReceipts } from '../receipt-matcher'
|
||||
|
||||
function createMockSupabase() {
|
||||
const mockResult = { data: null, error: null }
|
||||
|
||||
const chain = {
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
single: vi.fn().mockImplementation(() => Promise.resolve(mockResult)),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
is: vi.fn().mockReturnThis(),
|
||||
lt: vi.fn().mockReturnThis(),
|
||||
gte: vi.fn().mockReturnThis(),
|
||||
lte: vi.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
from: vi.fn().mockReturnValue(chain),
|
||||
}
|
||||
|
||||
return { supabase, chain, setResult: (data: unknown, error: unknown = null) => {
|
||||
mockResult.data = data as null
|
||||
mockResult.error = error as null
|
||||
} }
|
||||
}
|
||||
|
||||
const mockExtraction = {
|
||||
merchant: {
|
||||
name: 'ICA Maxi',
|
||||
orgNumber: '556123-4567',
|
||||
vatNumber: null,
|
||||
isForeign: false,
|
||||
},
|
||||
receipt: {
|
||||
date: '2024-06-15',
|
||||
time: '14:30',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [
|
||||
{ description: 'Mjölk', quantity: 1, unitPrice: 15, lineTotal: 15, vatRate: 12, suggestedCategory: 'other' },
|
||||
],
|
||||
totals: { subtotal: 13.39, vatAmount: 1.61, total: 15 },
|
||||
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
describe('processReceiptFromDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
vi.mocked(analyzeReceipt).mockResolvedValue(mockExtraction)
|
||||
vi.mocked(processLineItems).mockReturnValue([
|
||||
{ ...mockExtraction.lineItems[0], category: 'expense_other' as const, basAccount: '6991', confidence: 0.8 },
|
||||
])
|
||||
vi.mocked(autoMatchReceipts).mockReturnValue([])
|
||||
})
|
||||
|
||||
it('creates receipt record with extracted data', async () => {
|
||||
const { supabase, chain, setResult } = createMockSupabase()
|
||||
|
||||
const receipt = {
|
||||
id: 'receipt-1',
|
||||
user_id: 'user-1',
|
||||
status: 'extracted',
|
||||
merchant_name: 'ICA Maxi',
|
||||
total_amount: 15,
|
||||
receipt_date: '2024-06-15',
|
||||
}
|
||||
|
||||
// First from() = receipts insert
|
||||
setResult(receipt)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
documentId: 'doc-1',
|
||||
source: 'email',
|
||||
emailFrom: 'sender@example.com',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
})
|
||||
|
||||
expect(result.receipt.id).toBe('receipt-1')
|
||||
expect(analyzeReceipt).toHaveBeenCalledWith('base64data', 'image/jpeg')
|
||||
expect(processLineItems).toHaveBeenCalledWith(mockExtraction.lineItems)
|
||||
|
||||
// Verify receipt insert includes source and email_from
|
||||
expect(chain.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'email',
|
||||
email_from: 'sender@example.com',
|
||||
document_id: 'doc-1',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('emits receipt.extracted event', async () => {
|
||||
const { supabase, setResult } = createMockSupabase()
|
||||
const receipt = { id: 'receipt-1', user_id: 'user-1' }
|
||||
setResult(receipt)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
documentId: 'doc-1',
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
})
|
||||
|
||||
expect(eventBus.emit).toHaveBeenCalledWith({
|
||||
type: 'receipt.extracted',
|
||||
payload: expect.objectContaining({
|
||||
receipt,
|
||||
documentId: 'doc-1',
|
||||
confidence: 0.92,
|
||||
userId: 'user-1',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when receipt insert fails', async () => {
|
||||
const { supabase, setResult } = createMockSupabase()
|
||||
setResult(null, { message: 'insert failed' })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await expect(processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
documentId: null,
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
})).rejects.toThrow('Failed to create receipt')
|
||||
})
|
||||
|
||||
it('sets email_from to null when not provided', async () => {
|
||||
const { supabase, chain, setResult } = createMockSupabase()
|
||||
setResult({ id: 'receipt-1', user_id: 'user-1' })
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
|
||||
documentId: null,
|
||||
source: 'upload',
|
||||
storageUrl: 'https://storage.example.com/file.jpg',
|
||||
})
|
||||
|
||||
expect(chain.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'upload',
|
||||
email_from: null,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
|
||||
import { z } from 'zod'
|
||||
import type { ReceiptExtractionResult, ExtractedLineItem } from '@/types'
|
||||
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
|
||||
import {
|
||||
SYSTEMBOLAGET_PATTERNS,
|
||||
RESTAURANT_PATTERNS,
|
||||
@@ -54,6 +55,8 @@ VIKTIGT:
|
||||
- Belopp ska vara numeriska värden utan valutasymboler
|
||||
- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
|
||||
|
||||
const templateSection = buildTemplatePromptSection()
|
||||
|
||||
const userPrompt = `Analysera detta kvitto och extrahera strukturerad data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
@@ -77,7 +80,8 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
"unitPrice": 100.00,
|
||||
"lineTotal": 100.00,
|
||||
"vatRate": 25,
|
||||
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other"
|
||||
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other",
|
||||
"suggestedTemplateId": "mall-id eller null"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
@@ -90,10 +94,13 @@ Returnera ett JSON-objekt med följande struktur:
|
||||
"isSystembolaget": false,
|
||||
"isForeignMerchant": false
|
||||
},
|
||||
"confidence": 0.95
|
||||
"confidence": 0.95,
|
||||
"suggestedTemplateId": "mall-id för hela kvittot eller null"
|
||||
}
|
||||
|
||||
KATEGORIER för suggestedCategory:
|
||||
${templateSection}
|
||||
|
||||
KATEGORIER för suggestedCategory (backup om ingen mall matchar):
|
||||
- equipment: Datorer, telefoner, kameror, teknikprylar
|
||||
- software: Program, appar, molntjänster, prenumerationer
|
||||
- travel: Flyg, tåg, hotell, taxi
|
||||
@@ -224,6 +231,7 @@ function validateAndEnhanceResult(raw: unknown): ReceiptExtractionResult {
|
||||
isForeignMerchant: isForeign,
|
||||
},
|
||||
confidence: validateNumber(data.confidence) || 0.5,
|
||||
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -245,6 +253,7 @@ function validateLineItems(data: any): ExtractedLineItem[] {
|
||||
lineTotal: validateNumber(item.lineTotal) || 0,
|
||||
vatRate: validateNumber(item.vatRate),
|
||||
suggestedCategory: validateCategory(item.suggestedCategory),
|
||||
suggestedTemplateId: validateString(item.suggestedTemplateId) || undefined,
|
||||
confidence: validateNumber(item.confidence) || undefined,
|
||||
}))
|
||||
.filter((item) => item.lineTotal > 0 || item.description.length > 0)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { TransactionCategory, ReceiptLineItem, ExtractedLineItem } from '@/types'
|
||||
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
|
||||
|
||||
// Category mappings from suggested category to TransactionCategory
|
||||
const CATEGORY_MAPPING: Record<string, TransactionCategory> = {
|
||||
@@ -108,6 +109,16 @@ export function mapSuggestedCategory(suggestedCategory: string | null): Transact
|
||||
return CATEGORY_MAPPING[suggestedCategory] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a booking template ID to a TransactionCategory.
|
||||
* Falls back to the template's `fallback_category` field.
|
||||
*/
|
||||
export function mapTemplateIdToCategory(templateId: string | null | undefined): TransactionCategory | null {
|
||||
if (!templateId) return null
|
||||
const template = getTemplateById(templateId)
|
||||
return template?.fallback_category ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get BAS account for a category
|
||||
*/
|
||||
@@ -148,9 +159,15 @@ export function processLineItems(
|
||||
lineItems: ExtractedLineItem[]
|
||||
): Array<ExtractedLineItem & { category: TransactionCategory | null; basAccount: string | null }> {
|
||||
return lineItems.map((item) => {
|
||||
// First try the AI-suggested category
|
||||
let category = mapSuggestedCategory(item.suggestedCategory)
|
||||
let confidence = item.confidence || 0.8
|
||||
// First try the AI-suggested template ID
|
||||
let category = mapTemplateIdToCategory(item.suggestedTemplateId)
|
||||
let confidence = category ? (item.confidence || 0.85) : 0
|
||||
|
||||
// Then try the AI-suggested category
|
||||
if (!category) {
|
||||
category = mapSuggestedCategory(item.suggestedCategory)
|
||||
confidence = item.confidence || 0.8
|
||||
}
|
||||
|
||||
// If no AI suggestion, try pattern matching
|
||||
if (!category) {
|
||||
|
||||
@@ -3,14 +3,24 @@
|
||||
*
|
||||
* Uses date variance, amount tolerance, and merchant name similarity
|
||||
* to find potential transaction matches for receipts.
|
||||
*
|
||||
* Core matching utilities (levenshtein, merchant similarity) are imported
|
||||
* from @/lib/documents/core-receipt-matcher and re-exported for backward compat.
|
||||
*/
|
||||
|
||||
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
|
||||
import {
|
||||
calculateMerchantSimilarity,
|
||||
calculateMatchConfidence,
|
||||
levenshteinDistance,
|
||||
normalizeMerchantName,
|
||||
DATE_TOLERANCE_DAYS,
|
||||
AMOUNT_TOLERANCE_PERCENT,
|
||||
MIN_MATCH_CONFIDENCE,
|
||||
} from '@/lib/documents/core-receipt-matcher'
|
||||
|
||||
// Matching configuration
|
||||
const DATE_TOLERANCE_DAYS = 3 // Allow ±3 days variance
|
||||
const AMOUNT_TOLERANCE_PERCENT = 0.05 // Allow ±5% variance (for currency conversion)
|
||||
const MIN_MATCH_CONFIDENCE = 0.4 // Minimum confidence to consider a match
|
||||
// Re-export core functions for backward compatibility
|
||||
export { calculateMerchantSimilarity, levenshteinDistance, normalizeMerchantName }
|
||||
|
||||
/**
|
||||
* Find potential transaction matches for a receipt
|
||||
@@ -59,7 +69,7 @@ export function findTransactionMatches(
|
||||
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
|
||||
|
||||
// Calculate overall confidence
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
||||
const { confidence, matchReasons } = calculateReceiptMatchConfidence(
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
merchantSimilarity,
|
||||
@@ -83,140 +93,28 @@ export function findTransactionMatches(
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate merchant name similarity using Levenshtein distance
|
||||
* Wrapper around core calculateMatchConfidence that adds receipt-specific
|
||||
* MCC bonus logic for backward compatibility.
|
||||
*/
|
||||
function calculateMerchantSimilarity(name1: string, name2: string): number {
|
||||
if (!name1 || !name2) return 0
|
||||
|
||||
// Normalize names
|
||||
const n1 = normalizeMerchantName(name1)
|
||||
const n2 = normalizeMerchantName(name2)
|
||||
|
||||
// Check for exact match
|
||||
if (n1 === n2) return 1
|
||||
|
||||
// Check if one contains the other
|
||||
if (n1.includes(n2) || n2.includes(n1)) return 0.9
|
||||
|
||||
// Check for word overlap
|
||||
const words1 = n1.split(/\s+/)
|
||||
const words2 = n2.split(/\s+/)
|
||||
const commonWords = words1.filter((w) => words2.includes(w))
|
||||
|
||||
if (commonWords.length > 0) {
|
||||
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
|
||||
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
|
||||
}
|
||||
|
||||
// Calculate Levenshtein similarity
|
||||
const distance = levenshteinDistance(n1, n2)
|
||||
const maxLength = Math.max(n1.length, n2.length)
|
||||
const similarity = 1 - distance / maxLength
|
||||
|
||||
return similarity
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize merchant name for comparison
|
||||
*/
|
||||
function normalizeMerchantName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
|
||||
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
function levenshteinDistance(str1: string, str2: string): number {
|
||||
const m = str1.length
|
||||
const n = str2.length
|
||||
|
||||
// Create matrix
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(null)
|
||||
.map(() => Array(n + 1).fill(0))
|
||||
|
||||
// Initialize first row and column
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||
|
||||
// Fill the matrix
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1, // deletion
|
||||
dp[i][j - 1] + 1, // insertion
|
||||
dp[i - 1][j - 1] + cost // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall match confidence and reasons
|
||||
*/
|
||||
function calculateMatchConfidence(
|
||||
function calculateReceiptMatchConfidence(
|
||||
dateVariance: number,
|
||||
amountVariance: number,
|
||||
merchantSimilarity: number,
|
||||
receipt: Receipt,
|
||||
transaction: Transaction
|
||||
): { confidence: number; matchReasons: string[] } {
|
||||
const matchReasons: string[] = []
|
||||
let totalWeight = 0
|
||||
let weightedScore = 0
|
||||
const result = calculateMatchConfidence(dateVariance, amountVariance, merchantSimilarity)
|
||||
|
||||
// Date score (weight: 25%)
|
||||
const dateScore = Math.max(0, 1 - dateVariance / DATE_TOLERANCE_DAYS)
|
||||
if (dateScore >= 0.8) {
|
||||
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
|
||||
}
|
||||
weightedScore += dateScore * 0.25
|
||||
totalWeight += 0.25
|
||||
|
||||
// Amount score (weight: 40%)
|
||||
const amountScore = Math.max(0, 1 - amountVariance / AMOUNT_TOLERANCE_PERCENT)
|
||||
if (amountVariance < 0.01) {
|
||||
matchReasons.push('Exakt belopp')
|
||||
} else if (amountVariance < AMOUNT_TOLERANCE_PERCENT) {
|
||||
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
|
||||
}
|
||||
weightedScore += amountScore * 0.4
|
||||
totalWeight += 0.4
|
||||
|
||||
// Merchant score (weight: 35%)
|
||||
if (merchantSimilarity > 0) {
|
||||
if (merchantSimilarity >= 0.9) {
|
||||
matchReasons.push('Handlare matchar')
|
||||
} else if (merchantSimilarity >= 0.6) {
|
||||
matchReasons.push('Trolig handlarmatch')
|
||||
}
|
||||
weightedScore += merchantSimilarity * 0.35
|
||||
totalWeight += 0.35
|
||||
}
|
||||
|
||||
// Bonus for MCC match (if transaction has MCC and receipt is flagged)
|
||||
// Bonus for MCC match (receipt-specific: restaurant MCC codes)
|
||||
if (receipt.is_restaurant && transaction.mcc_code) {
|
||||
const restaurantMCCs = [5812, 5813, 5814]
|
||||
if (restaurantMCCs.includes(transaction.mcc_code)) {
|
||||
matchReasons.push('Restaurang MCC matchar')
|
||||
weightedScore += 0.1
|
||||
result.matchReasons.push('Restaurang MCC matchar')
|
||||
result.confidence = Math.round((result.confidence + 0.1) * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
|
||||
|
||||
return {
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
matchReasons,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Receipt Pipeline — shared, reusable receipt processing function.
|
||||
*
|
||||
* SERVER-ONLY: uses receipt-analyzer (Anthropic SDK).
|
||||
*
|
||||
* Extracts receipt data, categorizes line items, inserts records,
|
||||
* and attempts auto-matching against bank transactions.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Receipt, ReceiptMatchCandidate } from '@/types'
|
||||
import { analyzeReceipt } from './receipt-analyzer'
|
||||
import { processLineItems } from './receipt-categorizer'
|
||||
import { autoMatchReceipts } from './receipt-matcher'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
export interface ReceiptPipelineOptions {
|
||||
documentId: string | null
|
||||
source: 'upload' | 'camera' | 'email'
|
||||
emailFrom?: string
|
||||
storageUrl: string
|
||||
}
|
||||
|
||||
export interface ProcessedReceipt {
|
||||
receipt: Receipt
|
||||
lineItems: unknown[]
|
||||
matchedTransaction?: { transactionId: string; confidence: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a document through the receipt pipeline:
|
||||
* 1. Analyze with Claude Vision
|
||||
* 2. Categorize line items
|
||||
* 3. Insert receipt + line items
|
||||
* 4. Auto-match against unmatched transactions
|
||||
* 5. Emit receipt.extracted event
|
||||
*/
|
||||
export async function processReceiptFromDocument(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
base64: string,
|
||||
mimeType: string,
|
||||
opts: ReceiptPipelineOptions
|
||||
): Promise<ProcessedReceipt> {
|
||||
// 1. Analyze receipt with Claude Vision
|
||||
const validImageType = mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
const extraction = await analyzeReceipt(base64, validImageType)
|
||||
|
||||
// 2. Categorize line items
|
||||
const processedLineItems = processLineItems(extraction.lineItems)
|
||||
|
||||
// 3. Insert receipt record
|
||||
const { data: receipt, error: insertError } = await supabase
|
||||
.from('receipts')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
image_url: opts.storageUrl,
|
||||
status: 'extracted',
|
||||
extraction_confidence: extraction.confidence,
|
||||
merchant_name: extraction.merchant.name,
|
||||
merchant_org_number: extraction.merchant.orgNumber,
|
||||
merchant_vat_number: extraction.merchant.vatNumber,
|
||||
receipt_date: extraction.receipt.date,
|
||||
receipt_time: extraction.receipt.time,
|
||||
total_amount: extraction.totals.total,
|
||||
currency: extraction.receipt.currency,
|
||||
vat_amount: extraction.totals.vatAmount,
|
||||
is_restaurant: extraction.flags.isRestaurant,
|
||||
is_systembolaget: extraction.flags.isSystembolaget,
|
||||
is_foreign_merchant: extraction.flags.isForeignMerchant,
|
||||
raw_extraction: extraction,
|
||||
document_id: opts.documentId,
|
||||
source: opts.source,
|
||||
email_from: opts.emailFrom ?? null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !receipt) {
|
||||
throw new Error(`Failed to create receipt: ${insertError?.message}`)
|
||||
}
|
||||
|
||||
// 4. Insert line items
|
||||
if (processedLineItems.length > 0) {
|
||||
const lineItemsToInsert = processedLineItems.map((item, index) => ({
|
||||
receipt_id: receipt.id,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unitPrice,
|
||||
line_total: item.lineTotal,
|
||||
vat_rate: item.vatRate,
|
||||
vat_amount:
|
||||
item.vatRate && item.lineTotal
|
||||
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
|
||||
: null,
|
||||
extraction_confidence: item.confidence,
|
||||
suggested_category: item.suggestedCategory,
|
||||
category: item.category,
|
||||
bas_account: item.basAccount,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
|
||||
}
|
||||
|
||||
// 5. Auto-match against unmatched expense transactions (±7 days from receipt date)
|
||||
let matchedTransaction: ProcessedReceipt['matchedTransaction'] | undefined
|
||||
|
||||
if (extraction.receipt.date && extraction.totals.total) {
|
||||
const receiptDate = new Date(extraction.receipt.date)
|
||||
const dateFrom = new Date(receiptDate)
|
||||
dateFrom.setDate(dateFrom.getDate() - 7)
|
||||
const dateTo = new Date(receiptDate)
|
||||
dateTo.setDate(dateTo.getDate() + 7)
|
||||
|
||||
const { data: transactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('receipt_id', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', dateFrom.toISOString().split('T')[0])
|
||||
.lte('date', dateTo.toISOString().split('T')[0])
|
||||
|
||||
if (transactions && transactions.length > 0) {
|
||||
const matches = autoMatchReceipts([receipt], transactions, 0.8)
|
||||
if (matches.length > 0) {
|
||||
const best = matches[0]
|
||||
matchedTransaction = {
|
||||
transactionId: best.match.transaction.id,
|
||||
confidence: best.match.confidence,
|
||||
}
|
||||
|
||||
// Link receipt to transaction
|
||||
await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: best.match.transaction.id,
|
||||
match_confidence: best.match.confidence,
|
||||
})
|
||||
.eq('id', receipt.id)
|
||||
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: receipt.id })
|
||||
.eq('id', best.match.transaction.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Emit event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.extracted',
|
||||
payload: {
|
||||
receipt,
|
||||
documentId: opts.documentId,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
return { receipt, lineItems: processedLineItems, matchedTransaction }
|
||||
}
|
||||
@@ -311,6 +311,7 @@ export const CategorizeTransactionSchema = z.object({
|
||||
vat_treatment: VatTreatmentSchema.optional(),
|
||||
account_override: accountNumber.optional(),
|
||||
user_description: z.string().max(500).optional(),
|
||||
inbox_item_id: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
export const BookTransactionSchema = z.object({
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
BAS_REFERENCE,
|
||||
ACCOUNT_CLASS_LABELS,
|
||||
ACCOUNT_GROUP_LABELS,
|
||||
getBASReference,
|
||||
getBASReferenceByClass,
|
||||
isStandardBASAccount,
|
||||
} from '../bas-reference'
|
||||
|
||||
describe('BAS_REFERENCE data integrity', () => {
|
||||
it('contains the expected number of accounts (~1,276)', () => {
|
||||
expect(BAS_REFERENCE.length).toBeGreaterThanOrEqual(1250)
|
||||
expect(BAS_REFERENCE.length).toBeLessThanOrEqual(1300)
|
||||
})
|
||||
|
||||
it('has no duplicate account numbers', () => {
|
||||
const numbers = BAS_REFERENCE.map((a) => a.account_number)
|
||||
const uniqueNumbers = new Set(numbers)
|
||||
expect(uniqueNumbers.size).toBe(numbers.length)
|
||||
})
|
||||
|
||||
it('account_class matches the first digit of account_number', () => {
|
||||
for (const account of BAS_REFERENCE) {
|
||||
const firstDigit = parseInt(account.account_number[0], 10)
|
||||
expect(account.account_class).toBe(firstDigit)
|
||||
}
|
||||
})
|
||||
|
||||
it('account_group matches the first two digits of account_number', () => {
|
||||
for (const account of BAS_REFERENCE) {
|
||||
const firstTwo = account.account_number.substring(0, 2)
|
||||
expect(account.account_group).toBe(firstTwo)
|
||||
}
|
||||
})
|
||||
|
||||
it('every account has a non-null sru_code', () => {
|
||||
const withoutSru = BAS_REFERENCE.filter((a) => a.sru_code === null)
|
||||
expect(withoutSru).toEqual([])
|
||||
})
|
||||
|
||||
it('every account has a non-empty description', () => {
|
||||
const withoutDesc = BAS_REFERENCE.filter((a) => !a.description || a.description.trim() === '')
|
||||
expect(withoutDesc).toEqual([])
|
||||
})
|
||||
|
||||
it('every account has a valid account_type', () => {
|
||||
const validTypes = ['asset', 'liability', 'equity', 'revenue', 'expense', 'untaxed_reserves']
|
||||
for (const account of BAS_REFERENCE) {
|
||||
expect(validTypes).toContain(account.account_type)
|
||||
}
|
||||
})
|
||||
|
||||
it('every account has a valid normal_balance', () => {
|
||||
for (const account of BAS_REFERENCE) {
|
||||
expect(['debit', 'credit']).toContain(account.normal_balance)
|
||||
}
|
||||
})
|
||||
|
||||
it('all account numbers are 4 digits', () => {
|
||||
for (const account of BAS_REFERENCE) {
|
||||
expect(account.account_number).toMatch(/^\d{4}$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-standard accounts removed', () => {
|
||||
const nonStandard = ['1249', '1259', '1400', '1580', '3109', '4100', '4990', '7834', '7835', '7910', '8710']
|
||||
|
||||
for (const num of nonStandard) {
|
||||
it(`${num} is not in the catalog`, () => {
|
||||
expect(isStandardBASAccount(num)).toBe(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('Class 2 account_type correctness', () => {
|
||||
it('20xx accounts are equity', () => {
|
||||
const group20 = BAS_REFERENCE.filter((a) => a.account_group === '20')
|
||||
expect(group20.length).toBeGreaterThan(0)
|
||||
for (const a of group20) {
|
||||
expect(a.account_type).toBe('equity')
|
||||
}
|
||||
})
|
||||
|
||||
it('21xx accounts are untaxed_reserves', () => {
|
||||
const group21 = BAS_REFERENCE.filter((a) => a.account_group === '21')
|
||||
expect(group21.length).toBeGreaterThan(0)
|
||||
for (const a of group21) {
|
||||
expect(a.account_type).toBe('untaxed_reserves')
|
||||
}
|
||||
})
|
||||
|
||||
it('22xx-29xx accounts are liability', () => {
|
||||
const liabilityGroups = BAS_REFERENCE.filter(
|
||||
(a) => a.account_class === 2 && parseInt(a.account_group) >= 22
|
||||
)
|
||||
expect(liabilityGroups.length).toBeGreaterThan(0)
|
||||
for (const a of liabilityGroups) {
|
||||
expect(a.account_type).toBe('liability')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Class 8 normal_balance correctness', () => {
|
||||
it('8310 (Ränteintäkter) has credit normal_balance', () => {
|
||||
const account = getBASReference('8310')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('credit')
|
||||
})
|
||||
|
||||
it('8410 (Räntekostnader) has debit normal_balance', () => {
|
||||
const account = getBASReference('8410')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('debit')
|
||||
})
|
||||
|
||||
it('8910 (Skatt) has debit normal_balance', () => {
|
||||
const account = getBASReference('8910')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('debit')
|
||||
})
|
||||
|
||||
it('8810 (Bokslutsdispositioner) has credit normal_balance', () => {
|
||||
const account = getBASReference('8810')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('credit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Contra accounts have opposite normal_balance', () => {
|
||||
it('1119 (Ack. avskrivningar byggnader) has credit balance', () => {
|
||||
const account = getBASReference('1119')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('credit')
|
||||
})
|
||||
|
||||
it('1229 (Ack. avskrivningar inventarier) has credit balance', () => {
|
||||
const account = getBASReference('1229')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('credit')
|
||||
})
|
||||
|
||||
it('2011 (Egna varuuttag) has debit balance', () => {
|
||||
const account = getBASReference('2011')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('debit')
|
||||
})
|
||||
|
||||
it('3740 (Öres- och kronutjämning) has debit balance', () => {
|
||||
const account = getBASReference('3740')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.normal_balance).toBe('debit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('K2-excluded accounts', () => {
|
||||
const k2Excluded = [
|
||||
'1010', '1011', '1012', '1018', '1019',
|
||||
'1370', '1518',
|
||||
'2092', '2096', '2240', '2448',
|
||||
'3940', '7940',
|
||||
'8290', '8291', '8295',
|
||||
'8320', '8321', '8325',
|
||||
'8450', '8451', '8455',
|
||||
'8480', '8940',
|
||||
]
|
||||
|
||||
it('known K2-excluded accounts are marked correctly', () => {
|
||||
for (const num of k2Excluded) {
|
||||
const account = getBASReference(num)
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.k2_excluded).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('common non-K2-excluded accounts are not marked', () => {
|
||||
const normalAccounts = ['1510', '1930', '2440', '3001', '4010', '5010', '7010', '8310']
|
||||
for (const num of normalAccounts) {
|
||||
const account = getBASReference(num)
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.k2_excluded).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('total K2-excluded count matches expected (24)', () => {
|
||||
const k2Count = BAS_REFERENCE.filter((a) => a.k2_excluded).length
|
||||
expect(k2Count).toBe(24)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ACCOUNT_GROUP_LABELS coverage', () => {
|
||||
it('all groups present in BAS_REFERENCE have labels', () => {
|
||||
const groups = new Set(BAS_REFERENCE.map((a) => a.account_group))
|
||||
for (const group of groups) {
|
||||
expect(ACCOUNT_GROUP_LABELS[group]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('covers at least 70 groups', () => {
|
||||
expect(Object.keys(ACCOUNT_GROUP_LABELS).length).toBeGreaterThanOrEqual(70)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ACCOUNT_CLASS_LABELS', () => {
|
||||
it('has labels for all 8 classes', () => {
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
expect(ACCOUNT_CLASS_LABELS[i]).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Helper functions', () => {
|
||||
it('getBASReference returns correct account', () => {
|
||||
const account = getBASReference('1930')
|
||||
expect(account).toBeDefined()
|
||||
expect(account!.account_name).toBe('Företagskonto')
|
||||
expect(account!.account_type).toBe('asset')
|
||||
})
|
||||
|
||||
it('getBASReference returns undefined for non-existent account', () => {
|
||||
expect(getBASReference('9999')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getBASReferenceByClass returns accounts for each class', () => {
|
||||
for (let cls = 1; cls <= 8; cls++) {
|
||||
const accounts = getBASReferenceByClass(cls)
|
||||
expect(accounts.length).toBeGreaterThan(0)
|
||||
for (const a of accounts) {
|
||||
expect(a.account_class).toBe(cls)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('getBASReferenceByClass returns empty array for non-existent class', () => {
|
||||
expect(getBASReferenceByClass(9)).toEqual([])
|
||||
})
|
||||
|
||||
it('isStandardBASAccount returns true for standard accounts', () => {
|
||||
expect(isStandardBASAccount('1510')).toBe(true)
|
||||
expect(isStandardBASAccount('3001')).toBe(true)
|
||||
expect(isStandardBASAccount('8999')).toBe(true)
|
||||
})
|
||||
|
||||
it('isStandardBASAccount returns false for non-standard accounts', () => {
|
||||
expect(isStandardBASAccount('9999')).toBe(false)
|
||||
expect(isStandardBASAccount('0000')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Account class distribution', () => {
|
||||
it('class 1 (assets) has ~230 accounts', () => {
|
||||
const cls = getBASReferenceByClass(1)
|
||||
expect(cls.length).toBeGreaterThanOrEqual(220)
|
||||
expect(cls.length).toBeLessThanOrEqual(240)
|
||||
})
|
||||
|
||||
it('class 2 (equity & liabilities) has ~265 accounts', () => {
|
||||
const cls = getBASReferenceByClass(2)
|
||||
expect(cls.length).toBeGreaterThanOrEqual(255)
|
||||
expect(cls.length).toBeLessThanOrEqual(275)
|
||||
})
|
||||
|
||||
it('class 3 (revenue) has ~100 accounts', () => {
|
||||
const cls = getBASReferenceByClass(3)
|
||||
expect(cls.length).toBeGreaterThanOrEqual(90)
|
||||
expect(cls.length).toBeLessThanOrEqual(110)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
|
||||
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves'
|
||||
|
||||
export interface AccountDescription {
|
||||
name: string
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,917 @@
|
||||
import type { BASReferenceAccount } from '../bas-reference'
|
||||
|
||||
export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
|
||||
{
|
||||
account_number: '4000',
|
||||
account_name: 'Inköp av handelsvaror (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4010',
|
||||
account_name: 'Inköp av handelsvaror i Sverige',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader for inkop av varor avsedda for vidareforssaljning.',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4060',
|
||||
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4065',
|
||||
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4066',
|
||||
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4067',
|
||||
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4070',
|
||||
account_name: 'Inköp av handelsvaror från annat EU-land',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EU-land',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4075',
|
||||
account_name: 'Inköp av handelsvaror från annat EUland, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EUland, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4076',
|
||||
account_name: 'Inköp av handelsvaror från annat EUland, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EUland, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4077',
|
||||
account_name: 'Inköp av handelsvaror från annat EUland, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EUland, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4078',
|
||||
account_name: 'Inköp av handelsvaror från annat EUland, momsfri',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av handelsvaror från annat EUland, momsfri',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4080',
|
||||
account_name: 'Import av handelsvaror',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av handelsvaror',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4085',
|
||||
account_name: 'Import av handelsvaror, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av handelsvaror, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4086',
|
||||
account_name: 'Import av handelsvaror, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av handelsvaror, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4087',
|
||||
account_name: 'Import av handelsvaror, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av handelsvaror, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4090',
|
||||
account_name: 'Erhållna rabatter (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna rabatter (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4091',
|
||||
account_name: 'Erhållna kassarabatter (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna kassarabatter (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4092',
|
||||
account_name: 'Erhållna mängdrabatter (inkl. bonus) (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna mängdrabatter (inkl. bonus) (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4099',
|
||||
account_name: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB',
|
||||
account_class: 4,
|
||||
account_group: '40',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4200',
|
||||
account_name: 'Sålda handelsvaror VMB (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror VMB (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4210',
|
||||
account_name: 'Sålda handelsvaror VMB',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror VMB',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4211',
|
||||
account_name: 'Sålda handelsvaror positiv VMB 25 %',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror positiv VMB 25 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4212',
|
||||
account_name: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '42',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4300',
|
||||
account_name: 'Inköp av råvaror och material i Sverige (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '43',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4310',
|
||||
account_name: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '43',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4400',
|
||||
account_name: 'Inköp av råvaror och material, tjänster m.m. i Sverige, omvänd betalningsskyldighet (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material, tjänster m.m. i Sverige, omvänd betalningsskyldighet (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4410',
|
||||
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4415',
|
||||
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4416',
|
||||
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4417',
|
||||
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4420',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4425',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4426',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4427',
|
||||
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '44',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4500',
|
||||
account_name: 'Inköp av råvaror och material, tjänster m.m. från utlandet (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Varuinkop fran utlandet (ravaror och fornodenheter).',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4510',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4515',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land, 25 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land, 25 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4516',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land, 12 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land, 12 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4517',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land, 6 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land, 6 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4518',
|
||||
account_name: 'Inköp av råvaror och material från annat EU-land, momsfri',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av råvaror och material från annat EU-land, momsfri',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4530',
|
||||
account_name: 'Inköp av tjänster m.m. från utlandet',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster m.m. från utlandet',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4531',
|
||||
account_name: 'Inköp av tjänster från ett land utanför EU, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från ett land utanför EU, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4532',
|
||||
account_name: 'Inköp av tjänster från ett land utanför EU, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från ett land utanför EU, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4533',
|
||||
account_name: 'Inköp av tjänster från ett land utanför EU, 6 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från ett land utanför EU, 6 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4535',
|
||||
account_name: 'Inköp av tjänster från annat EU-land, 25 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från annat EU-land, 25 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4536',
|
||||
account_name: 'Inköp av tjänster från annat EU-land, 12 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från annat EU-land, 12 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4537',
|
||||
account_name: 'Inköp av tjänster från annat EU-land, 6 %',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från annat EU-land, 6 %',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4538',
|
||||
account_name: 'Inköp av tjänster från annat EU-land, momsfri',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster från annat EU-land, momsfri',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4540',
|
||||
account_name: 'Import av råvaror och material',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4545',
|
||||
account_name: 'Import av råvaror och material, 25 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material, 25 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4546',
|
||||
account_name: 'Import av råvaror och material, 12 % moms',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material, 12 % moms',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4547',
|
||||
account_name: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '45',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4600',
|
||||
account_name: 'Inköp av tjänster, underentreprenader och legoarbeten i Sverige (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '46',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader for arbete utfort av underleverantorer som del av leverans till kund.',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4610',
|
||||
account_name: 'Inköp av tjänster och underentreprenader',
|
||||
account_class: 4,
|
||||
account_group: '46',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av tjänster och underentreprenader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4670',
|
||||
account_name: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '46',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4700',
|
||||
account_name: 'Reduktion av inköpspriser (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Reduktion av inköpspriser (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4730',
|
||||
account_name: 'Erhållna rabatter (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna rabatter (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4731',
|
||||
account_name: 'Erhållna kassarabatter (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna kassarabatter (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4732',
|
||||
account_name: 'Erhållna mängdrabatter (inkl. bonus) (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Erhållna mängdrabatter (inkl. bonus) (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4739',
|
||||
account_name: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
account_class: 4,
|
||||
account_group: '47',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4800',
|
||||
account_name: 'Andra produktionskostnader (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Andra produktionskostnader (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4810',
|
||||
account_name: 'Kostnader för energi (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader för energi (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4820',
|
||||
account_name: 'Kostnader för drivmedel (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader för drivmedel (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4830',
|
||||
account_name: 'Kostnader för resor (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader för resor (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4840',
|
||||
account_name: 'Kostnader för hyra av utrustning (Råvaror och förnödenheter)',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kostnader för hyra av utrustning (Råvaror och förnödenheter)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4890',
|
||||
account_name: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
|
||||
account_class: 4,
|
||||
account_group: '48',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'debit',
|
||||
description: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4900',
|
||||
account_name: 'Förändring av lager (gruppkonto)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager (gruppkonto)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4910',
|
||||
account_name: 'Förändring av lager av råvaror',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av råvaror',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4920',
|
||||
account_name: 'Förändring av lager av tillsatsmaterial och förnödenheter',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av tillsatsmaterial och förnödenheter',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4940',
|
||||
account_name: 'Förändring av produkter i arbete',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av produkter i arbete',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4944',
|
||||
account_name: 'Förändring av produkter i arbete, material och utlägg',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av produkter i arbete, material och utlägg',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4945',
|
||||
account_name: 'Förändring av produkter i arbete, omkostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av produkter i arbete, omkostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4947',
|
||||
account_name: 'Förändring av produkter i arbete, personalkostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av produkter i arbete, personalkostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4950',
|
||||
account_name: 'Förändring av lager av färdiga varor',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av färdiga varor',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4960',
|
||||
account_name: 'Förändring av lager av handelsvaror',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av handelsvaror',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4970',
|
||||
account_name: 'Förändring av pågående arbeten, nedlagda kostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av pågående arbeten, nedlagda kostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4974',
|
||||
account_name: 'Förändring av pågående arbeten, material och utlägg',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av pågående arbeten, material och utlägg',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4975',
|
||||
account_name: 'Förändring av pågående arbeten, omkostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av pågående arbeten, omkostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4977',
|
||||
account_name: 'Förändring av pågående arbeten, personalkostnader',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av pågående arbeten, personalkostnader',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4980',
|
||||
account_name: 'Förändring av lager av värdepapper (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Förändring av lager av värdepapper (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4981',
|
||||
account_name: 'Sålda värdepappers anskaffningsvärde (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Sålda värdepappers anskaffningsvärde (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4987',
|
||||
account_name: 'Nedskrivning av värdepapper (Handelsvaror)',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Nedskrivning av värdepapper (Handelsvaror)',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '4988',
|
||||
account_name: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER',
|
||||
account_class: 4,
|
||||
account_group: '49',
|
||||
account_type: 'expense',
|
||||
normal_balance: 'credit',
|
||||
description: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER',
|
||||
sru_code: '7320',
|
||||
k2_excluded: false,
|
||||
},
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
import { CLASS_1_ACCOUNTS } from './class-1-assets'
|
||||
import { CLASS_2_ACCOUNTS } from './class-2-equity-liabilities'
|
||||
import { CLASS_3_ACCOUNTS } from './class-3-revenue'
|
||||
import { CLASS_4_ACCOUNTS } from './class-4-purchases'
|
||||
import { CLASS_5_ACCOUNTS } from './class-5-external-expenses'
|
||||
import { CLASS_6_ACCOUNTS } from './class-6-other-external'
|
||||
import { CLASS_7_ACCOUNTS } from './class-7-personnel'
|
||||
import { CLASS_8_ACCOUNTS } from './class-8-financial'
|
||||
|
||||
import type { BASReferenceAccount } from '../bas-reference'
|
||||
|
||||
export const BAS_REFERENCE: BASReferenceAccount[] = [
|
||||
...CLASS_1_ACCOUNTS,
|
||||
...CLASS_2_ACCOUNTS,
|
||||
...CLASS_3_ACCOUNTS,
|
||||
...CLASS_4_ACCOUNTS,
|
||||
...CLASS_5_ACCOUNTS,
|
||||
...CLASS_6_ACCOUNTS,
|
||||
...CLASS_7_ACCOUNTS,
|
||||
...CLASS_8_ACCOUNTS,
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* SRU Code Computation
|
||||
*
|
||||
* Replicates the range-based SRU code assignment logic from
|
||||
* supabase/migrations/20240101000021_sru_codes.sql.
|
||||
*
|
||||
* SRU codes are used for NE (enskild firma) and INK2 (aktiebolag) tax forms
|
||||
* filed with Skatteverket.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the SRU code for a given BAS account number.
|
||||
*
|
||||
* The logic applies NE-form codes first (higher priority for revenue/expense
|
||||
* accounts), then falls back to INK2 balance sheet codes.
|
||||
*/
|
||||
export function computeSRUCode(accountNumber: string): string | null {
|
||||
const num = accountNumber
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NE form codes (enskild firma) — fields 7310-7325
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// NE: R1 - Försäljning med moms (3000-3499 excl 3100)
|
||||
if (num >= '3000' && num <= '3499' && num !== '3100') return '7310'
|
||||
|
||||
// NE: R2 - Momsfria intäkter (3100, 3900, 3970-3980)
|
||||
if (num === '3100' || num === '3900' || (num >= '3970' && num <= '3980')) return '7311'
|
||||
|
||||
// NE: R3 - Bil/bostadsförmån (3200-3299) — overlaps with R1, R1 wins
|
||||
if (num >= '3200' && num <= '3299') return '7312'
|
||||
|
||||
// NE: R4 - Ränteintäkter (8310-8330)
|
||||
if (num >= '8310' && num <= '8330') return '7313'
|
||||
|
||||
// NE: R5 - Varuinköp (4000-4990)
|
||||
if (num >= '4000' && num <= '4990') return '7320'
|
||||
|
||||
// NE: R6 - Övriga kostnader (5000-6990, 7970)
|
||||
if ((num >= '5000' && num <= '6990') || num === '7970') return '7321'
|
||||
|
||||
// NE: R7 - Lönekostnader (7000-7699)
|
||||
if (num >= '7000' && num <= '7699') return '7322'
|
||||
|
||||
// NE: R8 - Räntekostnader (8400-8499)
|
||||
if (num >= '8400' && num <= '8499') return '7323'
|
||||
|
||||
// NE: R9 - Avskrivningar fastighet (7820)
|
||||
if (num === '7820') return '7324'
|
||||
|
||||
// NE: R10 - Avskrivningar övrigt (7700-7899 excl 7820)
|
||||
if (num >= '7700' && num <= '7899' && num !== '7820') return '7325'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INK2 form codes (aktiebolag) — fields 7201-7380
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// INK2: Immateriella anläggningstillgångar (1000-1099)
|
||||
if (num >= '1000' && num <= '1099') return '7201'
|
||||
|
||||
// INK2: Materiella anläggningstillgångar (1100-1299)
|
||||
if (num >= '1100' && num <= '1299') return '7202'
|
||||
|
||||
// INK2: Finansiella anläggningstillgångar (1300-1399)
|
||||
if (num >= '1300' && num <= '1399') return '7203'
|
||||
|
||||
// INK2: Varulager (1400-1499)
|
||||
if (num >= '1400' && num <= '1499') return '7210'
|
||||
|
||||
// INK2: Kundfordringar (1500-1599)
|
||||
if (num >= '1500' && num <= '1599') return '7211'
|
||||
|
||||
// INK2: Övriga omsättningstillgångar (1600-1999)
|
||||
if (num >= '1600' && num <= '1999') return '7212'
|
||||
|
||||
// INK2: Aktiekapital (2081)
|
||||
if (num === '2081') return '7220'
|
||||
|
||||
// INK2: Övrigt eget kapital (2085-2098)
|
||||
if (num >= '2085' && num <= '2098') return '7221'
|
||||
|
||||
// INK2: Årets resultat (2099)
|
||||
if (num === '2099') return '7222'
|
||||
|
||||
// INK2: Skulder (2100-2499)
|
||||
if (num >= '2100' && num <= '2499') return '7230'
|
||||
|
||||
// INK2: Övriga skulder (2500-2999)
|
||||
if (num >= '2500' && num <= '2999') return '7231'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INK2 remaining income statement (fallback for class 3-8 not covered by NE)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (num >= '3000' && num <= '3999') return '7310'
|
||||
if (num >= '4000' && num <= '4999') return '7320'
|
||||
if (num >= '5000' && num <= '6999') return '7330'
|
||||
if (num >= '7000' && num <= '7699') return '7340'
|
||||
if (num >= '7700' && num <= '7899') return '7350'
|
||||
if (num >= '7900' && num <= '7999') return '7360'
|
||||
if (num >= '8000' && num <= '8499') return '7370'
|
||||
if (num >= '8500' && num <= '8999') return '7380'
|
||||
|
||||
// Equity accounts not covered above (2000-2084)
|
||||
if (num >= '2000' && num <= '2084') return '7221'
|
||||
|
||||
return null
|
||||
}
|
||||
+53
-1939
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Template Prompt Builder
|
||||
*
|
||||
* Generates the booking template list section for AI extraction prompts.
|
||||
* Used by both receipt-analyzer and invoice-analyzer to stay in sync.
|
||||
*/
|
||||
|
||||
import { BOOKING_TEMPLATES } from './booking-templates'
|
||||
|
||||
/**
|
||||
* Build the template list section for AI prompts.
|
||||
* Lists all expense templates with their Swedish name, primary debit account, and VAT rate.
|
||||
*/
|
||||
export function buildTemplatePromptSection(): string {
|
||||
const expenseTemplates = BOOKING_TEMPLATES.filter((t) => t.direction === 'expense')
|
||||
|
||||
const lines = expenseTemplates.map((t) => {
|
||||
const vatInfo = t.vat_rate > 0 ? `moms ${t.vat_rate * 100}%` : 'momsfri'
|
||||
return `- ${t.id}: ${t.name_sv} (konto ${t.debit_account}, ${vatInfo})`
|
||||
})
|
||||
|
||||
return `BOKFÖRINGSMALLAR (välj den mest passande suggestedTemplateId):
|
||||
${lines.join('\n')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact template ID list for validation.
|
||||
*/
|
||||
export function getValidTemplateIds(): string[] {
|
||||
return BOOKING_TEMPLATES.map((t) => t.id)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock Anthropic SDK - vi.hoisted ensures the variable is available before vi.mock hoisting
|
||||
const { mockCreate } = vi.hoisted(() => {
|
||||
const mockCreate = vi.fn()
|
||||
return { mockCreate }
|
||||
})
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => {
|
||||
return {
|
||||
default: class MockAnthropic {
|
||||
messages = { create: mockCreate }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { classifyDocument } from '../classifier'
|
||||
|
||||
function makeResponse(json: Record<string, unknown>) {
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(json) }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('classifyDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('classifies a supplier invoice', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.95,
|
||||
reasoning: 'Contains invoice number, bankgiro, and supplier details',
|
||||
isReverseCharge: false,
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('supplier_invoice')
|
||||
expect(result.confidence).toBe(0.95)
|
||||
expect(result.isReverseCharge).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies a receipt', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.92,
|
||||
reasoning: 'Store receipt with line items and total',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('receipt')
|
||||
expect(result.confidence).toBe(0.92)
|
||||
expect(result.isReverseCharge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies a government letter', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'government_letter',
|
||||
confidence: 0.88,
|
||||
reasoning: 'Letter from Skatteverket',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('government_letter')
|
||||
expect(result.confidence).toBe(0.88)
|
||||
expect(result.isReverseCharge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies unknown documents', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'unknown',
|
||||
confidence: 0.5,
|
||||
reasoning: 'Cannot determine document type',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/png')
|
||||
|
||||
expect(result.type).toBe('unknown')
|
||||
expect(result.confidence).toBe(0.5)
|
||||
})
|
||||
|
||||
it('detects reverse charge on EU invoices', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.93,
|
||||
reasoning: 'EU invoice with reverse charge',
|
||||
isReverseCharge: true,
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(result.type).toBe('supplier_invoice')
|
||||
expect(result.isReverseCharge).toBe(true)
|
||||
})
|
||||
|
||||
it('retries on API error', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API timeout'))
|
||||
.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Receipt',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('receipt')
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws on JSON parse error without retry', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'not valid json' }],
|
||||
})
|
||||
|
||||
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
|
||||
'Failed to parse AI response'
|
||||
)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws on unsupported MIME type', async () => {
|
||||
await expect(classifyDocument('base64data', 'text/plain')).rejects.toThrow(
|
||||
'Unsupported file type: text/plain'
|
||||
)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to unknown for invalid type values', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'invalid_type',
|
||||
confidence: 0.8,
|
||||
reasoning: 'Test',
|
||||
})
|
||||
)
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.type).toBe('unknown')
|
||||
})
|
||||
|
||||
it('handles PDF content blocks correctly', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'supplier_invoice',
|
||||
confidence: 0.95,
|
||||
reasoning: 'PDF invoice',
|
||||
isReverseCharge: false,
|
||||
})
|
||||
)
|
||||
|
||||
await classifyDocument('base64data', 'application/pdf')
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'document' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('handles image content blocks correctly', async () => {
|
||||
mockCreate.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
type: 'receipt',
|
||||
confidence: 0.9,
|
||||
reasoning: 'Image receipt',
|
||||
})
|
||||
)
|
||||
|
||||
await classifyDocument('base64data', 'image/png')
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'image' }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('strips markdown code blocks from response', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '```json\n{"type":"receipt","confidence":0.9,"reasoning":"Test"}\n```',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = await classifyDocument('base64data', 'image/jpeg')
|
||||
expect(result.type).toBe('receipt')
|
||||
})
|
||||
|
||||
it('throws after max retries', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API error 1'))
|
||||
.mockRejectedValueOnce(new Error('API error 2'))
|
||||
.mockRejectedValueOnce(new Error('API error 3'))
|
||||
|
||||
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
|
||||
'Document classification failed after 3 attempts'
|
||||
)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
levenshteinDistance,
|
||||
normalizeMerchantName,
|
||||
calculateMerchantSimilarity,
|
||||
calculateMatchConfidence,
|
||||
} from '../core-receipt-matcher'
|
||||
|
||||
describe('levenshteinDistance', () => {
|
||||
it('returns 0 for identical strings', () => {
|
||||
expect(levenshteinDistance('abc', 'abc')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns length of other string for empty string', () => {
|
||||
expect(levenshteinDistance('', 'abc')).toBe(3)
|
||||
expect(levenshteinDistance('abc', '')).toBe(3)
|
||||
})
|
||||
|
||||
it('calculates correct edit distance', () => {
|
||||
expect(levenshteinDistance('kitten', 'sitting')).toBe(3)
|
||||
expect(levenshteinDistance('saturday', 'sunday')).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMerchantName', () => {
|
||||
it('lowercases and trims', () => {
|
||||
expect(normalizeMerchantName(' ICA MAXI ')).toBe('ica maxi')
|
||||
})
|
||||
|
||||
it('removes Swedish company suffixes', () => {
|
||||
expect(normalizeMerchantName('Telia AB')).toBe('telia')
|
||||
})
|
||||
|
||||
it('removes special characters but keeps Swedish letters', () => {
|
||||
expect(normalizeMerchantName('Café Överkås!')).toBe('café överkås')
|
||||
})
|
||||
|
||||
it('collapses whitespace', () => {
|
||||
expect(normalizeMerchantName('ica maxi stockholm')).toBe('ica maxi stockholm')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateMerchantSimilarity', () => {
|
||||
it('returns 1 for exact match', () => {
|
||||
expect(calculateMerchantSimilarity('ICA Maxi', 'ICA Maxi')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 1 for match after normalization', () => {
|
||||
expect(calculateMerchantSimilarity('Telia AB', 'telia')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0.9 when one contains the other', () => {
|
||||
expect(calculateMerchantSimilarity('ICA', 'ICA MAXI STOCKHOLM')).toBe(0.9)
|
||||
})
|
||||
|
||||
it('returns 0 for empty strings', () => {
|
||||
expect(calculateMerchantSimilarity('', 'abc')).toBe(0)
|
||||
expect(calculateMerchantSimilarity('abc', '')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns score between 0 and 1 for partial matches', () => {
|
||||
const score = calculateMerchantSimilarity('ICA Maxi', 'Coop Forum')
|
||||
expect(score).toBeGreaterThanOrEqual(0)
|
||||
expect(score).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('gives high score for word overlap', () => {
|
||||
const score = calculateMerchantSimilarity('ICA Maxi Stockholm', 'ICA Maxi Solna')
|
||||
expect(score).toBeGreaterThan(0.7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateMatchConfidence', () => {
|
||||
it('gives high confidence for exact date + amount + merchant', () => {
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(0, 0, 1.0)
|
||||
expect(confidence).toBeGreaterThan(0.9)
|
||||
expect(matchReasons).toContain('Exakt datum')
|
||||
expect(matchReasons).toContain('Exakt belopp')
|
||||
expect(matchReasons).toContain('Handlare matchar')
|
||||
})
|
||||
|
||||
it('gives lower confidence when date is off', () => {
|
||||
const exact = calculateMatchConfidence(0, 0, 1.0)
|
||||
const dateOff = calculateMatchConfidence(2, 0, 1.0)
|
||||
expect(dateOff.confidence).toBeLessThan(exact.confidence)
|
||||
})
|
||||
|
||||
it('gives lower confidence when amount is off', () => {
|
||||
const exact = calculateMatchConfidence(0, 0, 1.0)
|
||||
const amountOff = calculateMatchConfidence(0, 0.03, 1.0)
|
||||
expect(amountOff.confidence).toBeLessThan(exact.confidence)
|
||||
})
|
||||
|
||||
it('gives lower confidence with no merchant similarity when other signals are imperfect', () => {
|
||||
// With imperfect date/amount, missing merchant signal lowers overall confidence
|
||||
const withMerchant = calculateMatchConfidence(1, 0.02, 0.8)
|
||||
const noMerchant = calculateMatchConfidence(1, 0.02, 0)
|
||||
expect(noMerchant.confidence).toBeLessThan(withMerchant.confidence)
|
||||
})
|
||||
|
||||
it('respects custom tolerances', () => {
|
||||
// With wider tolerance, same variance should give higher score
|
||||
const narrow = calculateMatchConfidence(2, 0.03, 0.5, 3, 0.05)
|
||||
const wide = calculateMatchConfidence(2, 0.03, 0.5, 7, 0.10)
|
||||
expect(wide.confidence).toBeGreaterThan(narrow.confidence)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,352 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { matchDocumentToTransactions } from '../document-matcher'
|
||||
import { makeInvoiceInboxItem, makeTransaction } from '@/tests/helpers'
|
||||
import type { InvoiceExtractionResult, ReceiptExtractionResult, Transaction } from '@/types'
|
||||
|
||||
describe('matchDocumentToTransactions', () => {
|
||||
const mockSupabase = {} as never // Not used when candidateTransactions is provided
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('supplier_invoice matching', () => {
|
||||
const baseExtraction: InvoiceExtractionResult = {
|
||||
supplier: {
|
||||
name: 'Telia AB',
|
||||
orgNumber: '556103-4249',
|
||||
vatNumber: 'SE556103424901',
|
||||
address: 'Stockholm',
|
||||
bankgiro: '5820-5093',
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: 'INV-2024-001',
|
||||
invoiceDate: '2024-06-10',
|
||||
dueDate: '2024-06-20',
|
||||
paymentReference: '73401284756',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.95,
|
||||
}
|
||||
|
||||
it('returns null for government_letter type', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'government_letter',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no extracted_data', async () => {
|
||||
const item = makeInvoiceInboxItem({ extracted_data: null })
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no candidate transactions', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('pass 1: matches by payment reference with 0.98 confidence', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '73401284756',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
expect(result!.transactionId).toBe(tx.id)
|
||||
})
|
||||
|
||||
it('pass 1: matches with whitespace/dash-normalized references', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '734 012 847 56',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('pass 2: matches by exact amount + bankgiro with 0.92 confidence', async () => {
|
||||
const extractionNoRef = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoRef as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'BETALNING 58205093',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.92)
|
||||
expect(result!.method).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('pass 3: matches by exact amount + date proximity with 0.85 confidence', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-06-22',
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.85)
|
||||
expect(result!.method).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('pass 3: matches with lower confidence at 6–14 days', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-06-28', // 8 days after due date
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.75)
|
||||
expect(result!.method).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('pass 3: does not match if date is >14 days away', async () => {
|
||||
const extractionNoBg = {
|
||||
...baseExtraction,
|
||||
invoice: { ...baseExtraction.invoice, paymentReference: null },
|
||||
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'PAYMENT',
|
||||
date: '2024-07-06', // 16 days after due date
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('pass 4: matches by fuzzy amount + supplier name with 0.70 confidence', async () => {
|
||||
const extractionMinimal = {
|
||||
...baseExtraction,
|
||||
invoice: {
|
||||
...baseExtraction.invoice,
|
||||
paymentReference: null,
|
||||
dueDate: null,
|
||||
invoiceDate: null,
|
||||
},
|
||||
supplier: {
|
||||
...baseExtraction.supplier,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
name: 'Telia Sverige',
|
||||
},
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: extractionMinimal as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'telia faktura april',
|
||||
date: '2024-06-15',
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.70)
|
||||
expect(result!.method).toBe('amount_merchant')
|
||||
})
|
||||
|
||||
it('prefers higher confidence matches', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
status: 'ready',
|
||||
extracted_data: baseExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
|
||||
const txWithRef = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: '73401284756',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
const txWithAmount = makeTransaction({
|
||||
amount: -1000,
|
||||
reference: null,
|
||||
description: 'BETALNING',
|
||||
date: '2024-06-20',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [txWithAmount, txWithRef])
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.transactionId).toBe(txWithRef.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('receipt matching', () => {
|
||||
const receiptExtraction: ReceiptExtractionResult = {
|
||||
merchant: {
|
||||
name: 'ICA Maxi',
|
||||
orgNumber: null,
|
||||
vatNumber: null,
|
||||
isForeign: false,
|
||||
},
|
||||
receipt: {
|
||||
date: '2024-06-15',
|
||||
time: '14:30',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
|
||||
flags: {
|
||||
isRestaurant: false,
|
||||
isSystembolaget: false,
|
||||
isForeignMerchant: false,
|
||||
},
|
||||
confidence: 0.92,
|
||||
}
|
||||
|
||||
it('matches receipt to transaction with high confidence', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
description: 'ICA MAXI STOCKHOLM',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.method).toBe('receipt_match')
|
||||
expect(result!.confidence).toBeGreaterThanOrEqual(0.60)
|
||||
})
|
||||
|
||||
it('returns null when amount is too different', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -500,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when date is too far away', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-25', // 10 days after
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('skips transactions with existing receipt_id', async () => {
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({
|
||||
amount: -299,
|
||||
date: '2024-06-15',
|
||||
merchant_name: 'ICA Maxi',
|
||||
receipt_id: 'existing-receipt',
|
||||
})
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when total is 0 or null', async () => {
|
||||
const zeroExtraction = {
|
||||
...receiptExtraction,
|
||||
totals: { ...receiptExtraction.totals, total: 0 },
|
||||
}
|
||||
const item = makeInvoiceInboxItem({
|
||||
document_type: 'receipt',
|
||||
status: 'ready',
|
||||
extracted_data: zeroExtraction as unknown as Record<string, unknown>,
|
||||
})
|
||||
const tx = makeTransaction({ amount: -299, date: '2024-06-15' })
|
||||
|
||||
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Batch Document Matching
|
||||
*
|
||||
* Orchestrates matching multiple inbox items to transactions in a single sweep.
|
||||
* Fetches all unbooked transactions once, then runs per-item matching with
|
||||
* greedy assignment to prevent double-matching.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, Transaction } from '@/types'
|
||||
import { matchDocumentToTransactions, type DocumentMatchResult } from './document-matcher'
|
||||
|
||||
export interface BatchMatchResult {
|
||||
matched: number
|
||||
total: number
|
||||
matches: Array<{ inboxItemId: string; result: DocumentMatchResult }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a matching sweep for all ready unmatched inbox items.
|
||||
*
|
||||
* 1. Fetches all ready/processing inbox items without a matched_transaction_id
|
||||
* 2. Fetches all unbooked expense transactions
|
||||
* 3. Runs matching per item, greedily assigning (highest confidence first)
|
||||
* 4. Persists matches back to inbox items
|
||||
*/
|
||||
export async function runDocumentMatchingSweep(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
inboxItemIds?: string[]
|
||||
): Promise<BatchMatchResult> {
|
||||
// 1. Fetch unmatched inbox items
|
||||
let query = supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('matched_transaction_id', null)
|
||||
.in('status', ['ready', 'processing'])
|
||||
|
||||
if (inboxItemIds && inboxItemIds.length > 0) {
|
||||
query = query.in('id', inboxItemIds)
|
||||
}
|
||||
|
||||
const { data: inboxItems, error: itemsError } = await query
|
||||
|
||||
if (itemsError || !inboxItems || inboxItems.length === 0) {
|
||||
console.log(`[batch-match] No unmatched inbox items found`)
|
||||
return { matched: 0, total: 0, matches: [] }
|
||||
}
|
||||
|
||||
console.log(`[batch-match] Starting sweep: ${inboxItems.length} unmatched inbox items`)
|
||||
|
||||
// 2. Fetch all unbooked expense transactions (broad window: last 90 days)
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('is_business', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', ninetyDaysAgo.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError || !transactions || transactions.length === 0) {
|
||||
console.log(`[batch-match] No candidate transactions found (last 90 days)`)
|
||||
return { matched: 0, total: inboxItems.length, matches: [] }
|
||||
}
|
||||
|
||||
console.log(`[batch-match] ${transactions.length} candidate transactions (last 90 days)`)
|
||||
|
||||
// 3. Run matching for each item and collect results
|
||||
const pendingMatches: Array<{
|
||||
inboxItemId: string
|
||||
result: DocumentMatchResult
|
||||
}> = []
|
||||
|
||||
for (const item of inboxItems as InvoiceInboxItem[]) {
|
||||
const result = await matchDocumentToTransactions(
|
||||
supabase,
|
||||
userId,
|
||||
item,
|
||||
transactions as Transaction[]
|
||||
)
|
||||
if (result) {
|
||||
pendingMatches.push({ inboxItemId: item.id, result })
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Greedy assignment: sort by confidence desc, assign each transaction at most once
|
||||
pendingMatches.sort((a, b) => b.result.confidence - a.result.confidence)
|
||||
|
||||
const assignedTransactionIds = new Set<string>()
|
||||
const finalMatches: typeof pendingMatches = []
|
||||
|
||||
for (const match of pendingMatches) {
|
||||
if (assignedTransactionIds.has(match.result.transactionId)) {
|
||||
console.log(`[batch-match] Skipped item=${match.inboxItemId} → tx=${match.result.transactionId} (already assigned to higher-confidence match)`)
|
||||
continue // Transaction already assigned to a higher-confidence match
|
||||
}
|
||||
assignedTransactionIds.add(match.result.transactionId)
|
||||
finalMatches.push(match)
|
||||
}
|
||||
|
||||
console.log(`[batch-match] Sweep complete: ${finalMatches.length}/${inboxItems.length} items matched, ${pendingMatches.length - finalMatches.length} skipped (greedy dedup)`)
|
||||
|
||||
// 5. Persist matches
|
||||
for (const { inboxItemId, result } of finalMatches) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
matched_transaction_id: result.transactionId,
|
||||
match_confidence: result.confidence,
|
||||
match_method: result.method,
|
||||
})
|
||||
.eq('id', inboxItemId)
|
||||
.eq('user_id', userId)
|
||||
}
|
||||
|
||||
return {
|
||||
matched: finalMatches.length,
|
||||
total: inboxItems.length,
|
||||
matches: finalMatches,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Document Classifier using Claude Haiku Vision API
|
||||
*
|
||||
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
|
||||
* in server components or API routes.
|
||||
*
|
||||
* Classifies documents as supplier invoices, receipts, government letters,
|
||||
* or unknown. Also detects EU reverse charge for supplier invoices.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { DocumentClassificationType } from '@/types'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 1000
|
||||
|
||||
type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
|
||||
export interface DocumentClassification {
|
||||
type: DocumentClassificationType
|
||||
confidence: number
|
||||
reasoning: string
|
||||
isReverseCharge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a document using Claude Haiku Vision.
|
||||
* Determines if it's a supplier invoice, receipt, government letter, or unknown.
|
||||
*/
|
||||
export async function classifyDocument(
|
||||
base64: string,
|
||||
mimeType: string
|
||||
): Promise<DocumentClassification> {
|
||||
const systemPrompt = `Du är expert på att klassificera svenska affärsdokument.
|
||||
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)
|
||||
- government_letter: Myndighetspost (från Skatteverket, Bolagsverket, Försäkringskassan, kommun, etc.)
|
||||
- unknown: Annat dokument som inte passar ovan
|
||||
|
||||
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"?
|
||||
- Har leverantören ett VAT-nummer som INTE börjar med SE?
|
||||
Om ja: flagga isReverseCharge = true`
|
||||
|
||||
const userPrompt = `Klassificera detta dokument. Returnera ENDAST ett JSON-objekt:
|
||||
|
||||
{
|
||||
"type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
|
||||
"confidence": 0.95,
|
||||
"reasoning": "Kort förklaring",
|
||||
"isReverseCharge": false
|
||||
}
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
|
||||
const isPdf = mimeType === 'application/pdf'
|
||||
const isImage = mimeType.startsWith('image/')
|
||||
|
||||
if (!isPdf && !isImage) {
|
||||
throw new Error(`Unsupported file type: ${mimeType}`)
|
||||
}
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
|
||||
? [
|
||||
{
|
||||
type: 'document' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: 'application/pdf' as const,
|
||||
data: base64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: userPrompt },
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: mimeType as ImageMediaType,
|
||||
data: base64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: userPrompt },
|
||||
]
|
||||
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: contentBlocks }],
|
||||
system: systemPrompt,
|
||||
})
|
||||
|
||||
const content = message.content[0]
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from AI')
|
||||
}
|
||||
|
||||
let jsonText = content.text.trim()
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.slice(7)
|
||||
} else if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.slice(3)
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.slice(0, -3)
|
||||
}
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return validateClassification(parsed)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Failed to parse AI response: ${lastError.message}`)
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Document classification failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
|
||||
}
|
||||
|
||||
const VALID_TYPES: DocumentClassificationType[] = [
|
||||
'supplier_invoice',
|
||||
'receipt',
|
||||
'government_letter',
|
||||
'unknown',
|
||||
]
|
||||
|
||||
function validateClassification(raw: unknown): DocumentClassification {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid classification result: not an object')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = raw as any
|
||||
|
||||
const type = VALID_TYPES.includes(data.type) ? data.type : 'unknown'
|
||||
const confidence = typeof data.confidence === 'number' ? data.confidence : 0.5
|
||||
const reasoning = typeof data.reasoning === 'string' ? data.reasoning : ''
|
||||
const isReverseCharge = type === 'supplier_invoice' ? Boolean(data.isReverseCharge) : undefined
|
||||
|
||||
return { type, confidence, reasoning, isReverseCharge }
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Core Receipt Matcher — pure matching utility functions extracted from the
|
||||
* receipt-ocr extension so they can be reused by the document matching engine.
|
||||
*
|
||||
* These are pure functions with no Supabase or extension dependencies.
|
||||
*/
|
||||
|
||||
// Matching configuration (re-exported for consumers)
|
||||
export const DATE_TOLERANCE_DAYS = 3
|
||||
export const AMOUNT_TOLERANCE_PERCENT = 0.05
|
||||
export const MIN_MATCH_CONFIDENCE = 0.4
|
||||
|
||||
/**
|
||||
* Normalize a merchant name for comparison.
|
||||
* Removes special characters, Swedish company suffixes, and extra whitespace.
|
||||
*/
|
||||
export function normalizeMerchantName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
|
||||
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein (edit) distance between two strings.
|
||||
*/
|
||||
export function levenshteinDistance(str1: string, str2: string): number {
|
||||
const m = str1.length
|
||||
const n = str2.length
|
||||
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(null)
|
||||
.map(() => Array(n + 1).fill(0))
|
||||
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1, // deletion
|
||||
dp[i][j - 1] + 1, // insertion
|
||||
dp[i - 1][j - 1] + cost // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate merchant name similarity using Levenshtein distance and word overlap.
|
||||
* Returns a value between 0 (no match) and 1 (exact match).
|
||||
*/
|
||||
export function calculateMerchantSimilarity(name1: string, name2: string): number {
|
||||
if (!name1 || !name2) return 0
|
||||
|
||||
const n1 = normalizeMerchantName(name1)
|
||||
const n2 = normalizeMerchantName(name2)
|
||||
|
||||
// Exact match
|
||||
if (n1 === n2) return 1
|
||||
|
||||
// One contains the other
|
||||
if (n1.includes(n2) || n2.includes(n1)) return 0.9
|
||||
|
||||
// Word overlap
|
||||
const words1 = n1.split(/\s+/)
|
||||
const words2 = n2.split(/\s+/)
|
||||
const commonWords = words1.filter((w) => words2.includes(w))
|
||||
|
||||
if (commonWords.length > 0) {
|
||||
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
|
||||
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
|
||||
}
|
||||
|
||||
// Levenshtein similarity
|
||||
const distance = levenshteinDistance(n1, n2)
|
||||
const maxLength = Math.max(n1.length, n2.length)
|
||||
return 1 - distance / maxLength
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a weighted match confidence score from date, amount, and merchant signals.
|
||||
* Weights: amount 40%, merchant 35%, date 25%.
|
||||
*
|
||||
* When merchant similarity is 0, the merchant weight is excluded from the
|
||||
* total weight so the confidence is normalized across the active signals only.
|
||||
*/
|
||||
export function calculateMatchConfidence(
|
||||
dateVariance: number,
|
||||
amountVariance: number,
|
||||
merchantSimilarity: number,
|
||||
dateTolerance: number = DATE_TOLERANCE_DAYS,
|
||||
amountTolerance: number = AMOUNT_TOLERANCE_PERCENT
|
||||
): { confidence: number; matchReasons: string[] } {
|
||||
const matchReasons: string[] = []
|
||||
let totalWeight = 0
|
||||
let weightedScore = 0
|
||||
|
||||
// Date score (weight: 25%)
|
||||
const dateScore = Math.max(0, 1 - dateVariance / dateTolerance)
|
||||
if (dateScore >= 0.8) {
|
||||
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
|
||||
}
|
||||
weightedScore += dateScore * 0.25
|
||||
totalWeight += 0.25
|
||||
|
||||
// Amount score (weight: 40%)
|
||||
const amountScore = Math.max(0, 1 - amountVariance / amountTolerance)
|
||||
if (amountVariance < 0.01) {
|
||||
matchReasons.push('Exakt belopp')
|
||||
} else if (amountVariance < amountTolerance) {
|
||||
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
|
||||
}
|
||||
weightedScore += amountScore * 0.4
|
||||
totalWeight += 0.4
|
||||
|
||||
// Merchant score (weight: 35%) — only counted when there's data
|
||||
if (merchantSimilarity > 0) {
|
||||
if (merchantSimilarity >= 0.9) {
|
||||
matchReasons.push('Handlare matchar')
|
||||
} else if (merchantSimilarity >= 0.6) {
|
||||
matchReasons.push('Trolig handlarmatch')
|
||||
}
|
||||
weightedScore += merchantSimilarity * 0.35
|
||||
totalWeight += 0.35
|
||||
}
|
||||
|
||||
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
|
||||
|
||||
return {
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
matchReasons,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Document-to-Transaction Matcher
|
||||
*
|
||||
* Pure matching logic that works from extracted data already stored on inbox items.
|
||||
* Zero AI or extension dependencies — works entirely from structured data.
|
||||
*
|
||||
* Matching passes by document type:
|
||||
*
|
||||
* Supplier invoices:
|
||||
* 1. Payment reference exact match → 0.98
|
||||
* 2. Exact amount + bankgiro → 0.92
|
||||
* 3. Exact amount + date ±5 days → 0.85
|
||||
* 4. Fuzzy amount + supplier name → 0.70
|
||||
*
|
||||
* Receipts:
|
||||
* Weighted scoring (amount 40%, date 25%, merchant 35%), min confidence 0.60
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { InvoiceInboxItem, Transaction, InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
|
||||
import {
|
||||
calculateMerchantSimilarity,
|
||||
calculateMatchConfidence,
|
||||
} from './core-receipt-matcher'
|
||||
|
||||
export type DocumentMatchMethod =
|
||||
| 'payment_reference'
|
||||
| 'amount_date'
|
||||
| 'amount_merchant'
|
||||
| 'receipt_match'
|
||||
|
||||
export interface DocumentMatchResult {
|
||||
transactionId: string
|
||||
confidence: number
|
||||
method: DocumentMatchMethod
|
||||
matchReasons: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a single inbox item to the best candidate transaction.
|
||||
*
|
||||
* If `candidateTransactions` is not provided, fetches unbooked expense
|
||||
* transactions within ±7 days of the document date.
|
||||
*/
|
||||
export async function matchDocumentToTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
inboxItem: InvoiceInboxItem,
|
||||
candidateTransactions?: Transaction[]
|
||||
): Promise<DocumentMatchResult | null> {
|
||||
const tag = `[document-matcher] item=${inboxItem.id} type=${inboxItem.document_type}`
|
||||
|
||||
// Only match supplier invoices and receipts
|
||||
if (inboxItem.document_type === 'government_letter' || inboxItem.document_type === 'unknown') {
|
||||
console.log(`${tag} — skipped (unsupported document type)`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!inboxItem.extracted_data) {
|
||||
console.log(`${tag} — skipped (no extracted_data)`)
|
||||
return null
|
||||
}
|
||||
|
||||
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, userId, inboxItem))
|
||||
|
||||
console.log(`${tag} — ${transactions.length} candidate transactions`)
|
||||
|
||||
if (transactions.length === 0) {
|
||||
console.log(`${tag} — no candidates, aborting`)
|
||||
return null
|
||||
}
|
||||
|
||||
let result: DocumentMatchResult | null = null
|
||||
|
||||
if (inboxItem.document_type === 'supplier_invoice') {
|
||||
result = matchSupplierInvoiceDocument(inboxItem, transactions)
|
||||
} else if (inboxItem.document_type === 'receipt') {
|
||||
result = matchReceiptDocument(inboxItem, transactions)
|
||||
}
|
||||
|
||||
if (result) {
|
||||
console.log(`${tag} — MATCHED tx=${result.transactionId} confidence=${result.confidence} method=${result.method} reasons=[${result.matchReasons.join(', ')}]`)
|
||||
} else {
|
||||
console.log(`${tag} — no match found`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch unbooked expense transactions within ±7 days of the document date.
|
||||
*/
|
||||
async function fetchCandidateTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
inboxItem: InvoiceInboxItem
|
||||
): Promise<Transaction[]> {
|
||||
const docDate = getDocumentDate(inboxItem)
|
||||
if (!docDate) return []
|
||||
|
||||
const startDate = new Date(docDate)
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
const endDate = new Date(docDate)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.is('journal_entry_id', null)
|
||||
.is('is_business', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
.lte('date', endDate.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (error || !data) return []
|
||||
return data as Transaction[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most relevant date from an inbox item's extracted data.
|
||||
*/
|
||||
function getDocumentDate(inboxItem: InvoiceInboxItem): string | null {
|
||||
const data = inboxItem.extracted_data as Record<string, unknown> | null
|
||||
if (!data) return null
|
||||
|
||||
if (inboxItem.document_type === 'supplier_invoice') {
|
||||
const extraction = data as unknown as InvoiceExtractionResult
|
||||
return extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate ?? null
|
||||
}
|
||||
|
||||
if (inboxItem.document_type === 'receipt') {
|
||||
const extraction = data as unknown as ReceiptExtractionResult
|
||||
return extraction.receipt?.date ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a supplier invoice inbox item to transactions using a 4-pass algorithm.
|
||||
*/
|
||||
function matchSupplierInvoiceDocument(
|
||||
inboxItem: InvoiceInboxItem,
|
||||
transactions: Transaction[]
|
||||
): DocumentMatchResult | null {
|
||||
const tag = `[document-matcher:supplier] item=${inboxItem.id}`
|
||||
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
|
||||
if (!extraction) return null
|
||||
|
||||
const invoiceTotal = extraction.totals?.total
|
||||
if (invoiceTotal == null || invoiceTotal === 0) {
|
||||
console.log(`${tag} — no invoice total in extracted data`)
|
||||
return null
|
||||
}
|
||||
|
||||
const paymentRef = extraction.invoice?.paymentReference
|
||||
const bankgiro = extraction.supplier?.bankgiro
|
||||
const plusgiro = extraction.supplier?.plusgiro
|
||||
const supplierName = extraction.supplier?.name
|
||||
const dueDate = extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate
|
||||
|
||||
console.log(`${tag} — extracted: total=${invoiceTotal}, supplier=${supplierName || '?'}, dueDate=${dueDate || '?'}, paymentRef=${paymentRef || '?'}, bankgiro=${bankgiro || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
||||
|
||||
let bestMatch: DocumentMatchResult | null = null
|
||||
|
||||
for (const tx of transactions) {
|
||||
const txAmount = Math.abs(tx.amount)
|
||||
const txDesc = (tx.description || '').toLowerCase()
|
||||
const txRef = tx.reference || ''
|
||||
|
||||
// Pass 1: Payment reference exact match → 0.98
|
||||
if (paymentRef && txRef) {
|
||||
const normTxRef = txRef.replace(/\D/g, '')
|
||||
const normPayRef = paymentRef.replace(/\D/g, '')
|
||||
if (normTxRef && normPayRef && normTxRef === normPayRef) {
|
||||
console.log(`${tag} — Pass 1 HIT: tx=${tx.id} ref=${normPayRef}`)
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.98,
|
||||
method: 'payment_reference',
|
||||
matchReasons: ['Betalningsreferens matchar'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Exact amount + bankgiro/plusgiro → 0.92
|
||||
const amountMatch = Math.abs(txAmount - invoiceTotal) < 0.005
|
||||
if (amountMatch) {
|
||||
const bgNorm = bankgiro?.replace(/\D/g, '')
|
||||
const pgNorm = plusgiro?.replace(/\D/g, '')
|
||||
const hasBgMatch = bgNorm && txDesc.includes(bgNorm)
|
||||
const hasPgMatch = pgNorm && txDesc.includes(pgNorm)
|
||||
|
||||
if (hasBgMatch || hasPgMatch) {
|
||||
console.log(`${tag} — Pass 2 HIT: tx=${tx.id} amount=${txAmount} bg/pg match`)
|
||||
return {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.92,
|
||||
method: 'payment_reference',
|
||||
matchReasons: ['Exakt belopp', hasBgMatch ? 'Bankgiro matchar' : 'Plusgiro matchar'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Exact amount + date ±14 days → 0.85 (close) / 0.75 (wider)
|
||||
// Invoices are often paid early or a few days late, so we use a 14-day window.
|
||||
if (amountMatch && dueDate) {
|
||||
const txDate = new Date(tx.date)
|
||||
const docDate = new Date(dueDate)
|
||||
const diffDays = Math.abs((txDate.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays <= 14) {
|
||||
// Higher confidence for close dates, lower for wider window
|
||||
const confidence = diffDays <= 5 ? 0.85 : 0.75
|
||||
console.log(`${tag} — Pass 3 HIT: tx=${tx.id} amount=${txAmount} date_diff=${diffDays.toFixed(1)}d → confidence=${confidence}`)
|
||||
const candidate: DocumentMatchResult = {
|
||||
transactionId: tx.id,
|
||||
confidence,
|
||||
method: 'amount_date',
|
||||
matchReasons: ['Exakt belopp', diffDays === 0 ? 'Exakt datum' : `Datum ±${Math.round(diffDays)} dagar`],
|
||||
}
|
||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Fuzzy amount (±1%) + supplier name in description → 0.70
|
||||
const fuzzyAmountMatch = Math.abs(txAmount - invoiceTotal) / invoiceTotal <= 0.01
|
||||
if (fuzzyAmountMatch && supplierName) {
|
||||
const normalizedName = supplierName.toLowerCase().replace(/[^\w\såäöé]/g, '')
|
||||
const nameWords = normalizedName.split(/\s+/).filter((w) => w.length >= 3)
|
||||
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
|
||||
|
||||
if (nameInDesc) {
|
||||
console.log(`${tag} — Pass 4 HIT: tx=${tx.id} amount=${txAmount} (~${((Math.abs(txAmount - invoiceTotal) / invoiceTotal) * 100).toFixed(1)}%) name words=[${nameWords.join(',')}]`)
|
||||
const candidate: DocumentMatchResult = {
|
||||
transactionId: tx.id,
|
||||
confidence: 0.70,
|
||||
method: 'amount_merchant',
|
||||
matchReasons: ['Belopp matchar (±1%)', 'Leverantörsnamn i beskrivning'],
|
||||
}
|
||||
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a receipt inbox item to transactions using weighted scoring.
|
||||
* Weights: amount 40%, date 25%, merchant 35%. Min confidence: 0.60.
|
||||
*/
|
||||
function matchReceiptDocument(
|
||||
inboxItem: InvoiceInboxItem,
|
||||
transactions: Transaction[]
|
||||
): DocumentMatchResult | null {
|
||||
const tag = `[document-matcher:receipt] item=${inboxItem.id}`
|
||||
const extraction = inboxItem.extracted_data as unknown as ReceiptExtractionResult
|
||||
if (!extraction) return null
|
||||
|
||||
const receiptTotal = extraction.totals?.total
|
||||
const receiptDate = extraction.receipt?.date
|
||||
const merchantName = extraction.merchant?.name
|
||||
|
||||
if (receiptTotal == null || receiptTotal === 0) {
|
||||
console.log(`${tag} — no receipt total in extracted data`)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log(`${tag} — extracted: total=${receiptTotal}, date=${receiptDate || '?'}, merchant=${merchantName || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
|
||||
|
||||
let bestMatch: DocumentMatchResult | null = null
|
||||
|
||||
for (const tx of transactions) {
|
||||
if (tx.receipt_id) continue // Skip already matched
|
||||
|
||||
const txAmount = Math.abs(tx.amount)
|
||||
const txDate = new Date(tx.date)
|
||||
|
||||
// Calculate date variance
|
||||
const dateVariance = receiptDate
|
||||
? Math.abs((new Date(receiptDate).getTime() - txDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: 3 // Default to tolerance boundary if no date
|
||||
|
||||
if (dateVariance > 3) continue
|
||||
|
||||
// Calculate amount variance
|
||||
const amountVariance = Math.abs(receiptTotal - txAmount) / receiptTotal
|
||||
if (amountVariance > 0.05) continue // Skip if >5% off
|
||||
|
||||
// Calculate merchant similarity
|
||||
const txMerchant = tx.merchant_name || tx.description || ''
|
||||
const merchantSimilarity = merchantName
|
||||
? calculateMerchantSimilarity(merchantName, txMerchant)
|
||||
: 0
|
||||
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
merchantSimilarity
|
||||
)
|
||||
|
||||
console.log(`${tag} — scoring tx=${tx.id} "${tx.description}": date_var=${dateVariance.toFixed(1)}d amount_var=${(amountVariance * 100).toFixed(1)}% merchant_sim=${merchantSimilarity.toFixed(2)} → confidence=${confidence}`)
|
||||
|
||||
if (confidence >= 0.60 && (!bestMatch || confidence > bestMatch.confidence)) {
|
||||
bestMatch = {
|
||||
transactionId: tx.id,
|
||||
confidence,
|
||||
method: 'receipt_match',
|
||||
matchReasons,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InboxItemStatus, InvoiceExtractionResult } from '@/types'
|
||||
import type { InboxItemStatus, InvoiceExtractionResult, DocumentClassificationType } from '@/types'
|
||||
|
||||
const STATUS_LABELS: Record<InboxItemStatus, string> = {
|
||||
pending: 'Väntar',
|
||||
@@ -51,3 +51,43 @@ export function formatExtractionSummary(
|
||||
lineCount: data.lineItems?.length ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Document type labels (Swedish)
|
||||
const DOCUMENT_TYPE_LABELS: Record<DocumentClassificationType, string> = {
|
||||
supplier_invoice: 'Faktura',
|
||||
receipt: 'Kvitto',
|
||||
government_letter: 'Myndighetspost',
|
||||
unknown: 'Övrigt',
|
||||
}
|
||||
|
||||
export function getDocumentTypeLabel(type: DocumentClassificationType): string {
|
||||
return DOCUMENT_TYPE_LABELS[type] ?? type
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPE_VARIANTS: Record<DocumentClassificationType, string> = {
|
||||
supplier_invoice: 'default',
|
||||
receipt: 'secondary',
|
||||
government_letter: 'outline',
|
||||
unknown: 'outline',
|
||||
}
|
||||
|
||||
export function getDocumentTypeVariant(type: DocumentClassificationType): string {
|
||||
return DOCUMENT_TYPE_VARIANTS[type] ?? 'outline'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format extraction summary for receipt documents
|
||||
*/
|
||||
export function formatReceiptSummary(
|
||||
data: Record<string, unknown> | null | undefined
|
||||
): { merchantName: string; total: number } {
|
||||
if (!data) {
|
||||
return { merchantName: '', total: 0 }
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any
|
||||
return {
|
||||
merchantName: d.merchant?.name ?? '',
|
||||
total: d.totals?.total ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,15 +72,15 @@ export const SECTORS: Sector[] = [
|
||||
},
|
||||
{
|
||||
slug: 'invoice-inbox',
|
||||
name: 'Leverantörsfaktura-inbox',
|
||||
name: 'Dokumentinkorg',
|
||||
sector: 'general',
|
||||
category: 'import',
|
||||
icon: 'Inbox',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Ta emot leverantörsfakturor via e-post eller uppladdning',
|
||||
description: 'Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost',
|
||||
longDescription:
|
||||
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
|
||||
'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.',
|
||||
},
|
||||
{
|
||||
slug: 'calendar',
|
||||
|
||||
@@ -13,7 +13,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
||||
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
|
||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/DocumentInboxWorkspace')),
|
||||
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
'general/user-description-match': dynamic(() => import('@/components/extensions/general/UserDescriptionMatchWorkspace')),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { BASAccount } from '@/types'
|
||||
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import type { SIEAccount, SIEAccountMappingRecord } from '../types'
|
||||
import {
|
||||
suggestMappings,
|
||||
@@ -36,6 +37,7 @@ function makeBASAccount(number: string, name: string): BASAccount {
|
||||
default_vat_code: null,
|
||||
description: null,
|
||||
sru_code: null,
|
||||
k2_excluded: false,
|
||||
sort_order: parseInt(number, 10),
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
@@ -73,7 +75,7 @@ describe('suggestMappings', () => {
|
||||
expect(result[0].isOverride).toBe(false)
|
||||
})
|
||||
|
||||
it('returns unmapped entry when no match exists', () => {
|
||||
it('returns unmapped entry for out-of-range accounts', () => {
|
||||
const source = [makeSIEAccount('9999', 'Okänt konto')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
@@ -84,19 +86,40 @@ describe('suggestMappings', () => {
|
||||
expect(result[0].matchType).toBe('manual')
|
||||
})
|
||||
|
||||
it('does not fuzzy match accounts with similar names', () => {
|
||||
// 3400 should NOT match 3001 or 3002 despite being in same class
|
||||
it('self-maps valid BAS-range accounts not in reference via bas_range fallback', () => {
|
||||
// 3400 is a valid BAS-range account (1000-8999) but not in the fixture list
|
||||
const source = [makeSIEAccount('3400', 'Försäljning tjänster')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('3400')
|
||||
expect(result[0].targetName).toBe('Försäljning tjänster')
|
||||
expect(result[0].confidence).toBe(0.9)
|
||||
expect(result[0].matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
it('self-maps sub-accounts not in reference (e.g. 1241 Personbilar)', () => {
|
||||
const source = [makeSIEAccount('1241', 'Personbilar')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('1241')
|
||||
expect(result[0].targetName).toBe('Personbilar')
|
||||
expect(result[0].confidence).toBe(0.9)
|
||||
expect(result[0].matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
it('does not self-map accounts outside BAS range (9000+)', () => {
|
||||
const source = [makeSIEAccount('9100', 'Internt konto')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].targetAccount).toBe('')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
})
|
||||
|
||||
it('does not fuzzy match accounts with similar numbers', () => {
|
||||
// 2510 should NOT match 2440 despite being in same class
|
||||
const source = [makeSIEAccount('2510', 'Skatteskulder')]
|
||||
it('does not self-map non-4-digit account numbers', () => {
|
||||
const source = [makeSIEAccount('12345', 'Felaktigt kontonummer')]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
@@ -128,21 +151,25 @@ describe('suggestMappings', () => {
|
||||
expect(result[0].matchType).toBe('manual')
|
||||
})
|
||||
|
||||
it('sorts unmapped accounts first (lowest confidence)', () => {
|
||||
it('sorts by confidence (lowest first)', () => {
|
||||
const source = [
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
makeSIEAccount('3400', 'Försäljning tjänster'),
|
||||
makeSIEAccount('1930', 'Företagskonto'),
|
||||
]
|
||||
const result = suggestMappings(source, basAccounts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toHaveLength(4)
|
||||
// Unmapped (confidence 0) should come first
|
||||
expect(result[0].sourceAccount).toBe('9999')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
// Exact matches (confidence 1.0) come after
|
||||
expect(result[1].confidence).toBe(1.0)
|
||||
// bas_range (confidence 0.9) next
|
||||
expect(result[1].sourceAccount).toBe('3400')
|
||||
expect(result[1].confidence).toBe(0.9)
|
||||
// Exact matches (confidence 1.0) come last
|
||||
expect(result[2].confidence).toBe(1.0)
|
||||
expect(result[3].confidence).toBe(1.0)
|
||||
})
|
||||
|
||||
it('handles multiple accounts with mixed results', () => {
|
||||
@@ -155,10 +182,10 @@ describe('suggestMappings', () => {
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
|
||||
// All 3 should be mapped: 1510 and 5010 exact, 3400 bas_range
|
||||
const mapped = result.filter((m) => m.targetAccount)
|
||||
const unmapped = result.filter((m) => !m.targetAccount)
|
||||
expect(mapped).toHaveLength(2)
|
||||
expect(unmapped).toHaveLength(1)
|
||||
expect(mapped).toHaveLength(3)
|
||||
expect(mapped.find((m) => m.sourceAccount === '3400')?.matchType).toBe('bas_range')
|
||||
})
|
||||
|
||||
it('handles empty source accounts', () => {
|
||||
@@ -174,6 +201,48 @@ describe('suggestMappings', () => {
|
||||
expect(result[0].targetAccount).toBe('')
|
||||
expect(result[0].confidence).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts BASReferenceAccount objects (full BAS reference)', () => {
|
||||
const refAccounts: BASReferenceAccount[] = [
|
||||
{
|
||||
account_number: '1510',
|
||||
account_name: 'Kundfordringar',
|
||||
account_class: 1,
|
||||
account_group: '15',
|
||||
account_type: 'asset',
|
||||
normal_balance: 'debit',
|
||||
description: 'Kundfordringar',
|
||||
sru_code: null,
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '2440',
|
||||
account_name: 'Leverantörsskulder',
|
||||
account_class: 2,
|
||||
account_group: '24',
|
||||
account_type: 'liability',
|
||||
normal_balance: 'credit',
|
||||
description: 'Leverantörsskulder',
|
||||
sru_code: null,
|
||||
k2_excluded: false,
|
||||
},
|
||||
]
|
||||
|
||||
const source = [
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('2440', 'Leverantörsskulder'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
]
|
||||
|
||||
const result = suggestMappings(source, refAccounts)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
const mapped = result.filter((m) => m.targetAccount)
|
||||
const unmapped = result.filter((m) => !m.targetAccount)
|
||||
expect(mapped).toHaveLength(2)
|
||||
expect(unmapped).toHaveLength(1)
|
||||
expect(mapped.find((m) => m.sourceAccount === '1510')?.confidence).toBe(1.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateMappings', () => {
|
||||
@@ -188,7 +257,7 @@ describe('validateMappings', () => {
|
||||
expect(validation.unmappedAccounts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns invalid when accounts are unmapped', () => {
|
||||
it('returns invalid when out-of-range accounts are unmapped', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('9999', 'Okänt konto')],
|
||||
basAccounts
|
||||
@@ -200,6 +269,17 @@ describe('validateMappings', () => {
|
||||
expect(validation.unmappedAccounts).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns valid when all accounts mapped via exact + bas_range', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('1241', 'Personbilar')],
|
||||
basAccounts
|
||||
)
|
||||
const validation = validateMappings(mappings)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.unmappedAccounts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('detects low confidence accounts', () => {
|
||||
// With exact-match-only mapper, low confidence only comes from existing overrides
|
||||
const mappings = [
|
||||
@@ -236,18 +316,20 @@ describe('getMappingStats', () => {
|
||||
expect(stats.unmapped).toBe(1)
|
||||
})
|
||||
|
||||
it('counts match types correctly', () => {
|
||||
it('counts match types correctly including bas_range', () => {
|
||||
const mappings = suggestMappings(
|
||||
[
|
||||
makeSIEAccount('1510', 'Kundfordringar'),
|
||||
makeSIEAccount('9999', 'Okänt konto'),
|
||||
makeSIEAccount('1510', 'Kundfordringar'), // exact
|
||||
makeSIEAccount('1241', 'Personbilar'), // bas_range
|
||||
makeSIEAccount('9999', 'Okänt konto'), // manual (unmapped)
|
||||
],
|
||||
basAccounts
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
|
||||
expect(stats.exact).toBe(1)
|
||||
expect(stats.manual).toBe(1) // unmapped gets matchType 'manual'
|
||||
expect(stats.basRange).toBe(1)
|
||||
expect(stats.manual).toBe(1)
|
||||
expect(stats.name).toBe(0)
|
||||
expect(stats.class).toBe(0)
|
||||
})
|
||||
@@ -267,6 +349,20 @@ describe('getMappingStats', () => {
|
||||
expect(stats.averageConfidence).toBe(1.0)
|
||||
})
|
||||
|
||||
it('includes bas_range in average confidence calculation', () => {
|
||||
const mappings = suggestMappings(
|
||||
[
|
||||
makeSIEAccount('1510', 'Kundfordringar'), // exact, confidence 1.0
|
||||
makeSIEAccount('1241', 'Personbilar'), // bas_range, confidence 0.9
|
||||
],
|
||||
basAccounts
|
||||
)
|
||||
const stats = getMappingStats(mappings)
|
||||
|
||||
// Average of (1.0 + 0.9) / 2 = 0.95
|
||||
expect(stats.averageConfidence).toBe(0.95)
|
||||
})
|
||||
|
||||
it('returns 0 average confidence when nothing is mapped', () => {
|
||||
const mappings = suggestMappings(
|
||||
[makeSIEAccount('9999', 'Okänt konto')],
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* Account Mapping Engine
|
||||
*
|
||||
* Maps accounts from an imported SIE file to the user's BAS chart of accounts.
|
||||
* Uses exact account number matching only — no fuzzy/heuristic matching.
|
||||
* Uses exact account number matching against the BAS reference, with a fallback
|
||||
* for valid BAS-range sub-accounts (1000-8999) not in the reference.
|
||||
* This aligns with Swedish industry standard (e.g. Fortnox): exact match,
|
||||
* create new, or let the user map manually.
|
||||
*/
|
||||
|
||||
import type { BASAccount } from '@/types'
|
||||
import type {
|
||||
SIEAccount,
|
||||
AccountMapping,
|
||||
@@ -15,13 +15,35 @@ import type {
|
||||
SIEAccountMappingRecord,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Minimal account shape needed for mapping.
|
||||
* Both BASAccount (from user chart) and BASReferenceAccount (from reference data)
|
||||
* satisfy this interface.
|
||||
*/
|
||||
export type MappableAccount = {
|
||||
account_number: string
|
||||
account_name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account number is in the valid BAS range (1000-8999).
|
||||
* Standard Swedish BAS accounts are 4-digit numbers in classes 1-8.
|
||||
*/
|
||||
function isValidBASRange(accountNumber: string): boolean {
|
||||
if (!/^\d{4}$/.test(accountNumber)) return false
|
||||
const num = parseInt(accountNumber, 10)
|
||||
return num >= 1000 && num <= 8999
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching BAS account for a source account.
|
||||
* Only matches on exact account number — no fuzzy matching.
|
||||
* First tries exact match against the reference, then falls back to
|
||||
* self-mapping for valid BAS-range accounts not in the reference
|
||||
* (common for sub-accounts like 1241 Personbilar under 1240).
|
||||
*/
|
||||
function findBestMatch(
|
||||
source: SIEAccount,
|
||||
basAccounts: BASAccount[],
|
||||
basAccounts: MappableAccount[],
|
||||
existingOverride?: AccountMapping
|
||||
): AccountMapping | null {
|
||||
// If there's a user override, use it
|
||||
@@ -32,7 +54,7 @@ function findBestMatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Exact account number match
|
||||
// Exact account number match against reference
|
||||
const exactMatch = basAccounts.find(
|
||||
(target) => source.number === target.account_number
|
||||
)
|
||||
@@ -49,6 +71,21 @@ function findBestMatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if the account is a valid BAS-range number (1000-8999),
|
||||
// self-map it using the name from the SIE file. These are standard
|
||||
// BAS sub-accounts not in our reference (e.g. 1241 Personbilar).
|
||||
if (isValidBASRange(source.number) && source.name) {
|
||||
return {
|
||||
sourceAccount: source.number,
|
||||
sourceName: source.name,
|
||||
targetAccount: source.number,
|
||||
targetName: source.name,
|
||||
confidence: 0.9,
|
||||
matchType: 'bas_range',
|
||||
isOverride: false,
|
||||
}
|
||||
}
|
||||
|
||||
// No match found
|
||||
return null
|
||||
}
|
||||
@@ -58,7 +95,7 @@ function findBestMatch(
|
||||
*/
|
||||
export function suggestMappings(
|
||||
sourceAccounts: SIEAccount[],
|
||||
basAccounts: BASAccount[],
|
||||
basAccounts: MappableAccount[],
|
||||
existingMappings?: SIEAccountMappingRecord[]
|
||||
): AccountMapping[] {
|
||||
// Convert existing mappings to a lookup map
|
||||
@@ -132,6 +169,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
|
||||
mapped: number
|
||||
unmapped: number
|
||||
exact: number
|
||||
basRange: number
|
||||
name: number
|
||||
class: number
|
||||
manual: number
|
||||
@@ -143,6 +181,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
|
||||
const unmapped = total - mapped
|
||||
|
||||
const exact = mappings.filter((m) => m.matchType === 'exact').length
|
||||
const basRange = mappings.filter((m) => m.matchType === 'bas_range').length
|
||||
const name = mappings.filter((m) => m.matchType === 'name').length
|
||||
const classMatch = mappings.filter((m) => m.matchType === 'class').length
|
||||
const manual = mappings.filter((m) => m.matchType === 'manual').length
|
||||
@@ -159,6 +198,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
|
||||
mapped,
|
||||
unmapped,
|
||||
exact,
|
||||
basRange,
|
||||
name,
|
||||
class: classMatch,
|
||||
manual,
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export type SIEEncoding = 'cp437' | 'utf8'
|
||||
export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed'
|
||||
|
||||
// Match type for account mapping
|
||||
export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual'
|
||||
export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual' | 'bas_range'
|
||||
|
||||
// Parse issue severity
|
||||
export type ParseIssueSeverity = 'error' | 'warning' | 'info'
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { findSupplierInvoiceMatch } from '../supplier-invoice-matching'
|
||||
import { makeTransaction, makeSupplierInvoice, makeSupplier } from '@/tests/helpers'
|
||||
|
||||
describe('findSupplierInvoiceMatch', () => {
|
||||
const supplier = makeSupplier({
|
||||
name: 'Kontorsbolaget AB',
|
||||
bankgiro: '123-4567',
|
||||
plusgiro: '987654-3',
|
||||
})
|
||||
|
||||
it('returns null for empty invoice list', () => {
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
expect(findSupplierInvoiceMatch(tx, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for zero-amount transactions', () => {
|
||||
const tx = makeTransaction({ amount: 0 })
|
||||
const inv = makeSupplierInvoice({ status: 'registered', remaining_amount: 1000 })
|
||||
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
|
||||
})
|
||||
|
||||
it('skips paid invoices (remaining_amount = 0)', () => {
|
||||
const tx = makeTransaction({ amount: -1000, reference: '12345' })
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 0,
|
||||
payment_reference: '12345',
|
||||
})
|
||||
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
|
||||
})
|
||||
|
||||
it('skips invoices with non-matching status', () => {
|
||||
const tx = makeTransaction({ amount: -1000, reference: '12345' })
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'paid',
|
||||
remaining_amount: 1000,
|
||||
payment_reference: '12345',
|
||||
})
|
||||
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
|
||||
})
|
||||
|
||||
// Pass 1: Payment reference
|
||||
it('matches by payment reference with confidence 0.98', () => {
|
||||
const tx = makeTransaction({ amount: -5000, reference: '73100 12345 67890' })
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
payment_reference: '731001234567890',
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.matchMethod).toBe('payment_reference')
|
||||
})
|
||||
|
||||
// Pass 2: Amount + bankgiro
|
||||
it('matches by exact amount + bankgiro in description with confidence 0.92', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -10000,
|
||||
description: 'Betalning BG 1234567 Kontorsbolaget',
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'approved',
|
||||
remaining_amount: 10000,
|
||||
supplier: { ...supplier, bankgiro: '123-4567' },
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.92)
|
||||
expect(result!.matchMethod).toBe('amount_bankgiro')
|
||||
})
|
||||
|
||||
// Pass 3: Amount + date
|
||||
it('matches by exact amount + due date within 5 days with confidence 0.85', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -10000,
|
||||
date: '2024-07-03', // 2 days after due date
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 10000,
|
||||
due_date: '2024-07-01',
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.85)
|
||||
expect(result!.matchMethod).toBe('amount_date')
|
||||
})
|
||||
|
||||
it('does not match when date difference exceeds 5 days', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -10000,
|
||||
date: '2024-07-10', // 9 days after due date
|
||||
description: 'random payment',
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 10000,
|
||||
due_date: '2024-07-01',
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// Pass 4: Fuzzy amount + name
|
||||
it('matches by fuzzy amount + supplier name in description with confidence 0.70', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -10000,
|
||||
description: 'Betalning Kontorsbolaget',
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 10000,
|
||||
due_date: '2024-01-01', // far away date — won't match pass 3
|
||||
supplier: { ...supplier, name: 'Kontorsbolaget AB' },
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.70)
|
||||
expect(result!.matchMethod).toBe('fuzzy_name')
|
||||
})
|
||||
|
||||
it('prefers higher-confidence matches', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -5000,
|
||||
date: '2024-07-02',
|
||||
reference: '999888777',
|
||||
})
|
||||
|
||||
const invoiceRef = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
payment_reference: '999888777',
|
||||
due_date: '2024-07-01',
|
||||
})
|
||||
|
||||
const invoiceDate = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
due_date: '2024-07-01',
|
||||
})
|
||||
|
||||
// Payment reference match should win (0.98 > 0.85)
|
||||
const result = findSupplierInvoiceMatch(tx, [invoiceDate, invoiceRef])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
expect(result!.matchMethod).toBe('payment_reference')
|
||||
})
|
||||
|
||||
it('handles öresavrundning (±0.01 fuzzy)', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -999.99,
|
||||
description: 'Betalning Kontorsbolaget faktura',
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 1000,
|
||||
due_date: '2024-01-01',
|
||||
supplier: { ...supplier, name: 'Kontorsbolaget AB' },
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.confidence).toBe(0.70)
|
||||
})
|
||||
|
||||
it('ignores short words when matching supplier name', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -5000,
|
||||
description: 'AB payment', // "AB" is only 2 chars, should be ignored
|
||||
})
|
||||
const inv = makeSupplierInvoice({
|
||||
status: 'registered',
|
||||
remaining_amount: 5000,
|
||||
due_date: '2024-01-01',
|
||||
supplier: { ...supplier, name: 'AB' },
|
||||
})
|
||||
|
||||
const result = findSupplierInvoiceMatch(tx, [inv])
|
||||
|
||||
// "AB" is filtered out (length < 3), so no name match
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Supplier Invoice Matching — auto-match expense transactions to unpaid supplier invoices.
|
||||
*
|
||||
* 4-pass matching algorithm (ordered by confidence):
|
||||
* 1. Payment reference/OCR exact match → 0.98
|
||||
* 2. Exact amount + bankgiro/plusgiro match → 0.92
|
||||
* 3. Exact amount + date ±5 days → 0.85
|
||||
* 4. Fuzzy amount (±0.01) + supplier name in description → 0.70
|
||||
*
|
||||
* Auto-match threshold: ≥0.85 → applied automatically
|
||||
* Suggestion threshold: 0.70–0.85 → stored as potential_supplier_invoice_id
|
||||
*/
|
||||
|
||||
import type { Transaction, SupplierInvoice } from '@/types'
|
||||
|
||||
export interface SupplierInvoiceMatch {
|
||||
supplierInvoice: SupplierInvoice
|
||||
confidence: number
|
||||
matchMethod: 'payment_reference' | 'amount_bankgiro' | 'amount_date' | 'fuzzy_name'
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize payment reference for comparison (strip whitespace and non-digits).
|
||||
*/
|
||||
function normalizeReference(ref: string): string {
|
||||
return ref.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching supplier invoice for an expense transaction.
|
||||
* Expects invoices to have the `supplier` relation populated (for name/bankgiro matching).
|
||||
* Only matches against invoices with status 'registered' or 'approved'
|
||||
* and with remaining_amount > 0.
|
||||
*/
|
||||
export function findSupplierInvoiceMatch(
|
||||
transaction: Transaction,
|
||||
unpaidInvoices: SupplierInvoice[]
|
||||
): SupplierInvoiceMatch | null {
|
||||
if (unpaidInvoices.length === 0) return null
|
||||
|
||||
// Only match expense transactions
|
||||
const txAmount = Math.abs(transaction.amount)
|
||||
if (txAmount === 0) return null
|
||||
|
||||
let bestMatch: SupplierInvoiceMatch | null = null
|
||||
|
||||
for (const invoice of unpaidInvoices) {
|
||||
// Only match against registered/approved invoices with remaining amount
|
||||
if (!['registered', 'approved'].includes(invoice.status)) continue
|
||||
const remaining = invoice.remaining_amount ?? invoice.total
|
||||
if (remaining <= 0) continue
|
||||
|
||||
// Pass 1: Payment reference/OCR exact match → 0.98
|
||||
if (transaction.reference && invoice.payment_reference) {
|
||||
const txRef = normalizeReference(transaction.reference)
|
||||
const invRef = normalizeReference(invoice.payment_reference)
|
||||
if (txRef && invRef && txRef === invRef) {
|
||||
return {
|
||||
supplierInvoice: invoice,
|
||||
confidence: 0.98,
|
||||
matchMethod: 'payment_reference',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Exact amount + bankgiro/plusgiro match → 0.92
|
||||
const amountMatch = Math.abs(txAmount - remaining) < 0.005
|
||||
if (amountMatch) {
|
||||
const txDesc = (transaction.description || '').toLowerCase()
|
||||
const supplierBg = invoice.supplier?.bankgiro
|
||||
const supplierPg = invoice.supplier?.plusgiro
|
||||
const bgMatch = supplierBg && txDesc.includes(normalizeReference(supplierBg))
|
||||
const pgMatch = supplierPg && txDesc.includes(normalizeReference(supplierPg))
|
||||
|
||||
if (bgMatch || pgMatch) {
|
||||
return {
|
||||
supplierInvoice: invoice,
|
||||
confidence: 0.92,
|
||||
matchMethod: 'amount_bankgiro',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Exact amount + date ±5 days → 0.85
|
||||
if (amountMatch && invoice.due_date) {
|
||||
const txDate = new Date(transaction.date)
|
||||
const dueDate = new Date(invoice.due_date)
|
||||
const diffDays = Math.abs((txDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays <= 5) {
|
||||
const confidence = 0.85
|
||||
if (!bestMatch || confidence > bestMatch.confidence) {
|
||||
bestMatch = {
|
||||
supplierInvoice: invoice,
|
||||
confidence,
|
||||
matchMethod: 'amount_date',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Fuzzy amount (±0.01) + supplier name in description → 0.70
|
||||
const fuzzyAmountMatch = Math.abs(txAmount - remaining) <= 0.01
|
||||
const supplierName = invoice.supplier?.name
|
||||
if (fuzzyAmountMatch && supplierName) {
|
||||
const txDesc = (transaction.description || '').toLowerCase()
|
||||
const normalizedName = supplierName.toLowerCase()
|
||||
|
||||
// Check if any significant word from the supplier name appears in the description
|
||||
const nameWords = normalizedName
|
||||
.replace(/[^\w\såäöé]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length >= 3)
|
||||
|
||||
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
|
||||
|
||||
if (nameInDesc) {
|
||||
const confidence = 0.70
|
||||
if (!bestMatch || confidence > bestMatch.confidence) {
|
||||
bestMatch = {
|
||||
supplierInvoice: invoice,
|
||||
confidence,
|
||||
matchMethod: 'fuzzy_name',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
@@ -134,6 +134,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query (no booked transactions)
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch (no unpaid invoices)
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check returns null (no existing row)
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert returns the new transaction
|
||||
@@ -158,6 +160,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check returns an existing record
|
||||
enqueue({ data: { id: 'existing-tx-1' }, error: null })
|
||||
|
||||
@@ -177,6 +181,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup check: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert fails
|
||||
@@ -203,6 +209,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert returns the new transaction
|
||||
@@ -243,6 +251,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -272,6 +282,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -308,6 +320,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -337,6 +351,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -365,6 +381,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Transaction 1: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted1, error: null })
|
||||
@@ -402,6 +420,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Transaction rawNew: dedup (no match), insert OK
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: insertedNew, error: null })
|
||||
@@ -474,6 +494,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
@@ -498,6 +520,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: inserted, error: null })
|
||||
|
||||
@@ -548,6 +572,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -590,6 +616,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -617,6 +645,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked transaction map query
|
||||
enqueue({ data: [], error: null })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// Dedup: no duplicate
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -647,6 +677,8 @@ describe('ingestTransactions', () => {
|
||||
data: [{ date: '2024-06-15', amount: -250 }],
|
||||
error: null,
|
||||
})
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// external_id dedup: no match (different source)
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -670,6 +702,8 @@ describe('ingestTransactions', () => {
|
||||
data: [{ date: '2024-06-15', amount: -250 }],
|
||||
error: null,
|
||||
})
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// external_id dedup: no match
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
@@ -702,6 +736,8 @@ describe('ingestTransactions', () => {
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
// raw1: external_id dedup (no match) -> content dedup matches (bookedCount=2 -> 1)
|
||||
enqueue({ data: null, error: null })
|
||||
@@ -726,6 +762,8 @@ describe('ingestTransactions', () => {
|
||||
|
||||
// Booked map query throws (caught by try/catch in buildBookedTransactionMap)
|
||||
enqueue({ error: { message: 'Query failed' } })
|
||||
// Supplier invoices fetch
|
||||
enqueue({ data: [], error: null })
|
||||
// external_id dedup: no match
|
||||
enqueue({ data: null, error: null })
|
||||
// Insert
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { Transaction, RawTransaction, IngestResult } from '@/types'
|
||||
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice } from '@/types'
|
||||
|
||||
// Re-export types for backward compatibility
|
||||
export type { RawTransaction, IngestResult } from '@/types'
|
||||
@@ -92,6 +93,21 @@ export async function ingestTransactions(
|
||||
// Non-critical — reconciliation will be skipped
|
||||
}
|
||||
|
||||
// Pre-fetch unpaid supplier invoices for expense matching (non-critical)
|
||||
let unpaidSupplierInvoices: SupplierInvoice[] = []
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(*)')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['registered', 'approved'])
|
||||
.gt('remaining_amount', 0)
|
||||
|
||||
if (data) unpaidSupplierInvoices = data as SupplierInvoice[]
|
||||
} catch {
|
||||
// Non-critical — supplier invoice matching will be skipped
|
||||
}
|
||||
|
||||
for (const raw of rawTransactions) {
|
||||
// 1. Check for duplicates via external_id
|
||||
const { data: existing } = await supabase
|
||||
@@ -193,6 +209,36 @@ export async function ingestTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. For expense transactions, try supplier invoice matching
|
||||
if (newTransaction.amount < 0 && unpaidSupplierInvoices.length > 0) {
|
||||
try {
|
||||
const match = findSupplierInvoiceMatch(
|
||||
newTransaction as Transaction,
|
||||
unpaidSupplierInvoices
|
||||
)
|
||||
|
||||
if (match) {
|
||||
if (match.confidence >= 0.85) {
|
||||
// Auto-link at high confidence
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
result.auto_matched_invoices++
|
||||
} else {
|
||||
// Store as suggestion at lower confidence (0.70–0.85)
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — continue processing
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Evaluate mapping rules for auto-categorization
|
||||
try {
|
||||
const mappingResult = await evaluateMappingRules(
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
-- >>> SET THE TARGET USER EMAIL HERE <<<
|
||||
target_email TEXT := 'user@example.com';
|
||||
target_email TEXT := 'jakob.wennberg@arcim.io';
|
||||
target_user_id UUID;
|
||||
BEGIN
|
||||
-- Resolve email to user ID
|
||||
@@ -45,6 +45,17 @@ BEGIN
|
||||
ALTER TABLE public.audit_log DISABLE TRIGGER USER;
|
||||
ALTER TABLE public.company_settings DISABLE TRIGGER USER;
|
||||
ALTER TABLE public.profiles DISABLE TRIGGER USER;
|
||||
ALTER TABLE public.chat_sessions DISABLE TRIGGER USER;
|
||||
ALTER TABLE public.invoice_inbox_items DISABLE TRIGGER USER;
|
||||
|
||||
-- Temporarily drop fiscal period constraints (migrations 042-043).
|
||||
-- The NOT VALID CHECK constraints are still enforced on UPDATE, so
|
||||
-- the circular-FK-breaking UPDATEs below will fail on rows with
|
||||
-- non-conforming dates. The EXCLUDE constraint can also interfere.
|
||||
-- We re-add them all after deletion.
|
||||
ALTER TABLE public.fiscal_periods DROP CONSTRAINT IF EXISTS no_overlapping_fiscal_periods;
|
||||
ALTER TABLE public.fiscal_periods DROP CONSTRAINT IF EXISTS fiscal_period_start_first_of_month;
|
||||
ALTER TABLE public.fiscal_periods DROP CONSTRAINT IF EXISTS fiscal_period_end_last_of_month;
|
||||
|
||||
-- Break circular / self-referencing FK constraints
|
||||
UPDATE public.fiscal_periods SET closing_entry_id = NULL, opening_balance_entry_id = NULL, previous_period_id = NULL WHERE user_id = target_user_id;
|
||||
@@ -61,6 +72,7 @@ BEGIN
|
||||
DELETE FROM public.invoice_reminders WHERE user_id = target_user_id;
|
||||
DELETE FROM public.supplier_invoice_items WHERE supplier_invoice_id IN (SELECT id FROM public.supplier_invoices WHERE user_id = target_user_id);
|
||||
DELETE FROM public.supplier_invoice_payments WHERE supplier_invoice_id IN (SELECT id FROM public.supplier_invoices WHERE user_id = target_user_id);
|
||||
DELETE FROM public.invoice_inbox_items WHERE user_id = target_user_id;
|
||||
DELETE FROM public.document_attachments WHERE user_id = target_user_id;
|
||||
DELETE FROM public.journal_entry_lines WHERE journal_entry_id IN (SELECT id FROM public.journal_entries WHERE user_id = target_user_id);
|
||||
DELETE FROM public.journal_entries WHERE user_id = target_user_id;
|
||||
@@ -85,6 +97,9 @@ BEGIN
|
||||
DELETE FROM public.cost_centers WHERE user_id = target_user_id;
|
||||
DELETE FROM public.projects WHERE user_id = target_user_id;
|
||||
DELETE FROM public.bank_file_imports WHERE user_id = target_user_id;
|
||||
DELETE FROM public.chat_messages WHERE user_id = target_user_id;
|
||||
DELETE FROM public.chat_sessions WHERE user_id = target_user_id;
|
||||
DELETE FROM public.extension_data WHERE user_id = target_user_id;
|
||||
DELETE FROM public.audit_log WHERE user_id = target_user_id;
|
||||
DELETE FROM public.extension_toggles WHERE user_id = target_user_id;
|
||||
DELETE FROM public.company_settings WHERE user_id = target_user_id;
|
||||
@@ -107,6 +122,26 @@ BEGIN
|
||||
ALTER TABLE public.audit_log ENABLE TRIGGER USER;
|
||||
ALTER TABLE public.company_settings ENABLE TRIGGER USER;
|
||||
ALTER TABLE public.profiles ENABLE TRIGGER USER;
|
||||
ALTER TABLE public.chat_sessions ENABLE TRIGGER USER;
|
||||
ALTER TABLE public.invoice_inbox_items ENABLE TRIGGER USER;
|
||||
|
||||
-- Re-add fiscal period constraints
|
||||
ALTER TABLE public.fiscal_periods
|
||||
ADD CONSTRAINT no_overlapping_fiscal_periods
|
||||
EXCLUDE USING gist (
|
||||
user_id WITH =,
|
||||
daterange(period_start, period_end, '[]') WITH &&
|
||||
);
|
||||
|
||||
ALTER TABLE public.fiscal_periods
|
||||
ADD CONSTRAINT fiscal_period_start_first_of_month
|
||||
CHECK (EXTRACT(DAY FROM period_start) = 1)
|
||||
NOT VALID;
|
||||
|
||||
ALTER TABLE public.fiscal_periods
|
||||
ADD CONSTRAINT fiscal_period_end_last_of_month
|
||||
CHECK (period_end = (date_trunc('month', period_end) + interval '1 month - 1 day')::date)
|
||||
NOT VALID;
|
||||
|
||||
-- Delete the auth user
|
||||
DELETE FROM auth.users WHERE id = target_user_id;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cpSync, rmSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const root = process.cwd()
|
||||
const extensions = ['receipt-ocr', 'ai-categorization', 'ai-chat', 'push-notifications', 'enable-banking', 'example-logger']
|
||||
|
||||
for (const ext of extensions) {
|
||||
const src = join(root, 'extensions', ext)
|
||||
const dest = join(root, 'extensions', 'general', ext)
|
||||
|
||||
if (!existsSync(src)) {
|
||||
console.log(`SKIP: ${src} does not exist`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (existsSync(dest)) {
|
||||
console.log(`CLEAN: ${dest} already exists, removing`)
|
||||
rmSync(dest, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(`COPY: ${src} -> ${dest}`)
|
||||
cpSync(src, dest, { recursive: true })
|
||||
}
|
||||
|
||||
console.log('Done copying extensions to general/')
|
||||
|
||||
// Now remove the old directories
|
||||
for (const ext of extensions) {
|
||||
const src = join(root, 'extensions', ext)
|
||||
if (existsSync(src)) {
|
||||
console.log(`REMOVE: ${src}`)
|
||||
rmSync(src, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Done removing old extension directories')
|
||||
@@ -1,33 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const extsDir = path.join(root, 'extensions');
|
||||
const generalDir = path.join(extsDir, 'general');
|
||||
|
||||
const dirs = [
|
||||
'receipt-ocr',
|
||||
'ai-categorization',
|
||||
'ai-chat',
|
||||
'push-notifications',
|
||||
'enable-banking',
|
||||
'example-logger',
|
||||
];
|
||||
|
||||
if (!fs.existsSync(generalDir)) {
|
||||
fs.mkdirSync(generalDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const ext of dirs) {
|
||||
const src = path.join(extsDir, ext);
|
||||
const dest = path.join(generalDir, ext);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.cpSync(src, dest, { recursive: true });
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
console.log('Done: ' + ext);
|
||||
} else {
|
||||
console.log('Skip: ' + ext);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All done.');
|
||||
@@ -1 +0,0 @@
|
||||
const fs=require('fs'),p=require('path'),r=p.resolve(__dirname,'..'),b=p.join(r,'extensions'),t=p.join(b,'general'),d=['receipt-ocr','ai-categorization','ai-chat','push-notifications','enable-banking','example-logger'];fs.mkdirSync(t,{recursive:!0});d.forEach(n=>{const s=p.join(b,n),e=p.join(t,n);fs.existsSync(s)?(fs.cpSync(s,e,{recursive:!0}),fs.rmSync(s,{recursive:!0,force:!0}),console.log(n)):console.log('!'+n)});
|
||||
@@ -0,0 +1,165 @@
|
||||
-- Migration 42: Full BAS 2026 support
|
||||
-- Adds k2_excluded column and backfills K2-excluded account numbers.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. Add k2_excluded column
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.chart_of_accounts
|
||||
ADD COLUMN IF NOT EXISTS k2_excluded boolean DEFAULT false;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Backfill k2_excluded = true for BAS 2026 K2-excluded accounts
|
||||
-- These accounts are marked with # in BAS Kontoplan 2026 v1.0 and should
|
||||
-- not be used when K2 accounting framework is applied.
|
||||
-- =============================================================================
|
||||
|
||||
UPDATE public.chart_of_accounts
|
||||
SET k2_excluded = true, updated_at = now()
|
||||
WHERE account_number IN (
|
||||
'1010', '1011', '1012', '1018', '1019',
|
||||
'1370',
|
||||
'1518',
|
||||
'2092', '2096',
|
||||
'2240',
|
||||
'2448',
|
||||
'3940',
|
||||
'7940',
|
||||
'8290', '8291', '8295',
|
||||
'8320', '8321', '8325',
|
||||
'8450', '8451', '8455',
|
||||
'8480',
|
||||
'8940'
|
||||
)
|
||||
AND k2_excluded = false;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. Re-run SRU code backfill for any accounts with sru_code IS NULL
|
||||
-- Uses the same range logic as migration 021.
|
||||
-- =============================================================================
|
||||
|
||||
-- NE: R1 - Försäljning med moms (3000-3499 excl 3100)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7310', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND account_number >= '3000' AND account_number <= '3499'
|
||||
AND account_number != '3100';
|
||||
|
||||
-- NE: R2 - Momsfria intäkter (3100, 3900, 3970-3980)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7311', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND (
|
||||
account_number = '3100'
|
||||
OR account_number = '3900'
|
||||
OR (account_number >= '3970' AND account_number <= '3980')
|
||||
);
|
||||
|
||||
-- NE: R4 - Ränteintäkter (8310-8330)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7313', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND account_number >= '8310' AND account_number <= '8330';
|
||||
|
||||
-- NE: R5 - Varuinköp (4000-4990)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7320', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND account_number >= '4000' AND account_number <= '4990';
|
||||
|
||||
-- NE: R6 - Övriga kostnader (5000-6990, 7970)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7321', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND (
|
||||
(account_number >= '5000' AND account_number <= '6990')
|
||||
OR account_number = '7970'
|
||||
);
|
||||
|
||||
-- NE: R7 - Lönekostnader (7000-7699)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7322', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND account_number >= '7000' AND account_number <= '7699';
|
||||
|
||||
-- NE: R8 - Räntekostnader (8400-8499)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7323', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND account_number >= '8400' AND account_number <= '8499';
|
||||
|
||||
-- NE: R9 - Avskrivningar fastighet (7820)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7324', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND account_number = '7820';
|
||||
|
||||
-- NE: R10 - Avskrivningar övrigt (7700-7899 excl 7820)
|
||||
UPDATE public.chart_of_accounts
|
||||
SET sru_code = '7325', updated_at = now()
|
||||
WHERE sru_code IS NULL
|
||||
AND account_number >= '7700' AND account_number <= '7899'
|
||||
AND account_number != '7820';
|
||||
|
||||
-- INK2: Balance sheet fallbacks
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7201', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '1000' AND account_number <= '1099';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7202', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '1100' AND account_number <= '1299';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7203', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '1300' AND account_number <= '1399';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7210', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '1400' AND account_number <= '1499';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7211', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '1500' AND account_number <= '1599';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7212', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '1600' AND account_number <= '1999';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7220', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number = '2081';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7221', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '2085' AND account_number <= '2098';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7222', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number = '2099';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7230', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '2100' AND account_number <= '2499';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7231', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '2500' AND account_number <= '2999';
|
||||
|
||||
-- INK2: Remaining income statement fallbacks
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7310', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '3000' AND account_number <= '3999';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7320', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '4000' AND account_number <= '4999';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7330', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '5000' AND account_number <= '6999';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7340', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '7000' AND account_number <= '7699';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7350', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '7700' AND account_number <= '7899';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7360', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '7900' AND account_number <= '7999';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7370', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '8000' AND account_number <= '8499';
|
||||
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7380', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '8500' AND account_number <= '8999';
|
||||
|
||||
-- Equity accounts not covered above (2000-2084)
|
||||
UPDATE public.chart_of_accounts SET sru_code = '7221', updated_at = now()
|
||||
WHERE sru_code IS NULL AND account_number >= '2000' AND account_number <= '2084';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Document matching: add columns to invoice_inbox_items for transaction matching
|
||||
-- and AI template suggestions.
|
||||
|
||||
-- Suggested booking template from AI extraction
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD COLUMN suggested_template_id TEXT,
|
||||
ADD COLUMN suggested_template_confidence NUMERIC;
|
||||
|
||||
-- Matched bank transaction
|
||||
ALTER TABLE public.invoice_inbox_items
|
||||
ADD COLUMN matched_transaction_id UUID REFERENCES public.transactions(id) ON DELETE SET NULL,
|
||||
ADD COLUMN match_confidence NUMERIC,
|
||||
ADD COLUMN match_method TEXT CHECK (match_method IN ('payment_reference', 'amount_date', 'amount_merchant', 'receipt_match'));
|
||||
|
||||
-- Index for looking up which inbox item is matched to a transaction
|
||||
CREATE INDEX idx_inbox_items_matched_transaction
|
||||
ON public.invoice_inbox_items (user_id, matched_transaction_id)
|
||||
WHERE matched_transaction_id IS NOT NULL;
|
||||
|
||||
-- Index for finding unmatched ready items for sweep
|
||||
CREATE INDEX idx_inbox_items_unmatched_ready
|
||||
ON public.invoice_inbox_items (user_id, status)
|
||||
WHERE matched_transaction_id IS NULL AND status IN ('ready', 'processing');
|
||||
@@ -118,6 +118,9 @@ export function makeReceipt(overrides: Partial<Receipt> = {}): Receipt {
|
||||
is_foreign_merchant: false,
|
||||
representation_persons: null,
|
||||
representation_purpose: null,
|
||||
representation_business_connection: null,
|
||||
source: 'upload',
|
||||
email_from: null,
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
raw_extraction: null,
|
||||
@@ -145,6 +148,7 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
|
||||
invoice_id: null,
|
||||
supplier_invoice_id: null,
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
mcc_code: null,
|
||||
merchant_name: 'ICA Maxi',
|
||||
@@ -467,6 +471,14 @@ export function makeInvoiceInboxItem(
|
||||
matched_supplier_id: null,
|
||||
created_supplier_invoice_id: null,
|
||||
error_message: null,
|
||||
document_type: 'supplier_invoice',
|
||||
linked_receipt_id: null,
|
||||
raw_email_payload: null,
|
||||
suggested_template_id: null,
|
||||
suggested_template_confidence: null,
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
match_method: null,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
updated_at: '2024-06-15T14:30:00Z',
|
||||
...overrides,
|
||||
|
||||
+33
-1
@@ -223,6 +223,9 @@ export interface Transaction {
|
||||
// Potential invoice match (suggested, not confirmed)
|
||||
potential_invoice_id: string | null
|
||||
|
||||
// Potential supplier invoice match (suggested, not confirmed)
|
||||
potential_supplier_invoice_id: string | null
|
||||
|
||||
// Bookkeeping
|
||||
journal_entry_id: string | null
|
||||
mcc_code: number | null
|
||||
@@ -759,7 +762,7 @@ export interface TaxEstimate {
|
||||
export type RiskLevel = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
|
||||
|
||||
// Account types
|
||||
export type AccountType = 'asset' | 'equity' | 'liability' | 'revenue' | 'expense'
|
||||
export type AccountType = 'asset' | 'equity' | 'liability' | 'revenue' | 'expense' | 'untaxed_reserves'
|
||||
export type NormalBalance = 'debit' | 'credit'
|
||||
export type PlanType = 'k1' | 'full_bas'
|
||||
|
||||
@@ -810,6 +813,7 @@ export interface BASAccount {
|
||||
default_vat_code: string | null
|
||||
description: string | null
|
||||
sru_code: string | null
|
||||
k2_excluded: boolean
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -1344,6 +1348,9 @@ export interface SIEAccountMapping {
|
||||
export type InboxItemStatus = 'pending' | 'processing' | 'ready' | 'confirmed' | 'rejected' | 'error'
|
||||
export type InboxItemSource = 'email' | 'upload'
|
||||
|
||||
// Document classification type for unified inbox routing
|
||||
export type DocumentClassificationType = 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'
|
||||
|
||||
export interface InvoiceInboxItem {
|
||||
id: string
|
||||
user_id: string
|
||||
@@ -1358,6 +1365,21 @@ export interface InvoiceInboxItem {
|
||||
matched_supplier_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
error_message: string | null
|
||||
|
||||
// Unified document inbox fields
|
||||
document_type: DocumentClassificationType
|
||||
linked_receipt_id: string | null
|
||||
raw_email_payload: Record<string, unknown> | null
|
||||
|
||||
// AI template suggestion
|
||||
suggested_template_id: string | null
|
||||
suggested_template_confidence: number | null
|
||||
|
||||
// Transaction matching
|
||||
matched_transaction_id: string | null
|
||||
match_confidence: number | null
|
||||
match_method: 'payment_reference' | 'amount_date' | 'amount_merchant' | 'receipt_match' | null
|
||||
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -1365,6 +1387,7 @@ export interface InvoiceInboxItem {
|
||||
document?: DocumentAttachment
|
||||
supplier?: Supplier
|
||||
supplier_invoice?: SupplierInvoice
|
||||
receipt?: Receipt
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -1405,6 +1428,11 @@ export interface Receipt {
|
||||
// Restaurant representation data
|
||||
representation_persons: number | null
|
||||
representation_purpose: string | null
|
||||
representation_business_connection: string | null
|
||||
|
||||
// Source tracking (for email-originated receipts)
|
||||
source: 'upload' | 'camera' | 'email'
|
||||
email_from: string | null
|
||||
|
||||
// Transaction matching
|
||||
matched_transaction_id: string | null
|
||||
@@ -1472,6 +1500,7 @@ export interface ReceiptExtractionResult {
|
||||
isForeignMerchant: boolean
|
||||
}
|
||||
confidence: number
|
||||
suggestedTemplateId?: string
|
||||
}
|
||||
|
||||
// Extracted line item from AI
|
||||
@@ -1482,6 +1511,7 @@ export interface ExtractedLineItem {
|
||||
lineTotal: number
|
||||
vatRate: number | null
|
||||
suggestedCategory: string | null
|
||||
suggestedTemplateId?: string
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
@@ -1939,6 +1969,7 @@ export interface InvoiceExtractionResult {
|
||||
}
|
||||
vatBreakdown: VatBreakdownItem[]
|
||||
confidence: number
|
||||
suggestedTemplateId?: string
|
||||
}
|
||||
|
||||
export interface ExtractedInvoiceLineItem {
|
||||
@@ -1948,6 +1979,7 @@ export interface ExtractedInvoiceLineItem {
|
||||
lineTotal: number
|
||||
vatRate: number | null
|
||||
accountSuggestion: string | null
|
||||
suggestedTemplateId?: string
|
||||
}
|
||||
|
||||
export interface VatBreakdownItem {
|
||||
|
||||
Reference in New Issue
Block a user