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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user