- Add currency-utils module for SEK conversion with exchange rates - Refactor createJournalEntry to use draft+commit flow preventing voucher number gaps (BFL 5 kap. 7§) - Add foreign currency support to invoice entries with per-line SEK conversion - Centralize category-to-account mapping into single source of truth - Refactor invoice inbox to use shared document analyzer with document type classification (receipt, supplier invoice, government letter) - Update mapping engine, supplier invoice entries, and transaction entries - Fix report component rendering issues - Add new validation schemas and tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1238 lines
39 KiB
TypeScript
1238 lines
39 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
|
import type { InvoiceExtractionResult } from './types'
|
|
import type { InvoiceInboxItem, SupplierInvoice } from '@/types'
|
|
import { analyzeDocument } from '@/lib/ai/document-analyzer'
|
|
import { matchSupplier } from './lib/supplier-matcher'
|
|
import { getSettings, saveSettings } from './index'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { matchDocumentToTransactions } from '@/lib/documents/document-matcher'
|
|
import crypto from 'crypto'
|
|
|
|
// ============================================================
|
|
// Helpers
|
|
// ============================================================
|
|
|
|
async function getSupabase() {
|
|
const { createClient } = await import('@/lib/supabase/server')
|
|
return createClient()
|
|
}
|
|
|
|
function getIdParam(request: Request): string | null {
|
|
const { searchParams } = new URL(request.url)
|
|
return searchParams.get('_id')
|
|
}
|
|
|
|
// ============================================================
|
|
// GET /inbox — List inbox items
|
|
// ============================================================
|
|
|
|
async function handleGetInbox(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const supabase = await getSupabase()
|
|
|
|
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), receipt:receipts(id, merchant_name, total_amount, receipt_date, status, matched_transaction_id)')
|
|
.eq('user_id', userId)
|
|
|
|
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) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /inbox — Upload file(s) to inbox
|
|
// ============================================================
|
|
|
|
async function handlePostInbox(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const supabase = await getSupabase()
|
|
|
|
const formData = await request.formData()
|
|
|
|
// 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']
|
|
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, userId, file)
|
|
items.push(result.inboxItem)
|
|
|
|
// Process asynchronously
|
|
processInboxItem(result.inboxItem.id as string, userId, 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}`)
|
|
}
|
|
}
|
|
|
|
// Return array for batch, single item for backward compat
|
|
if (files.length === 1 && items.length === 1) {
|
|
return NextResponse.json({ data: items[0] })
|
|
}
|
|
|
|
return NextResponse.json({ data: items, errors: errors.length > 0 ? errors : undefined })
|
|
}
|
|
|
|
async function uploadAndCreateInboxItem(
|
|
supabase: Awaited<ReturnType<typeof getSupabase>>,
|
|
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')
|
|
|
|
const storagePath = `documents/${userId}/inbox/${Date.now()}-${file.name}`
|
|
const { error: uploadError } = await supabase.storage
|
|
.from('documents')
|
|
.upload(storagePath, buffer, { contentType: file.type })
|
|
|
|
if (uploadError) {
|
|
throw new Error('Failed to upload file')
|
|
}
|
|
|
|
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()
|
|
|
|
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(
|
|
itemId: string,
|
|
userId: string,
|
|
base64: string,
|
|
mimeType: string
|
|
): Promise<void> {
|
|
const supabase = await getSupabase()
|
|
|
|
try {
|
|
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI analysis (${mimeType})`)
|
|
const result = await analyzeDocument(base64, mimeType)
|
|
|
|
const { classification } = result
|
|
console.log(`[invoice-inbox] item=${itemId} classified as: ${classification.type} (confidence=${classification.confidence}, reasoning=${classification.reasoning})`)
|
|
|
|
// Handle based on document type
|
|
if (classification.type === 'receipt' && result.receipt) {
|
|
await processAsReceipt(supabase, itemId, userId, result.receipt)
|
|
} else if (classification.type === 'supplier_invoice' && result.invoice) {
|
|
await processAsInvoice(supabase, itemId, userId, result.invoice)
|
|
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
|
|
// Store classification but no extraction — user must review manually
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
status: 'ready',
|
|
document_type: classification.type,
|
|
confidence: classification.confidence,
|
|
})
|
|
.eq('id', itemId)
|
|
} else {
|
|
// Classification gave a type but extraction failed — fall back to supplier_invoice extraction
|
|
console.warn(`[invoice-inbox] item=${itemId}: classified as ${classification.type} but no extraction data, falling back`)
|
|
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
|
|
const fallbackExtraction = await extractInvoice(base64, mimeType)
|
|
await processAsInvoice(supabase, itemId, userId, fallbackExtraction)
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({ status: 'error', error_message: message })
|
|
.eq('id', itemId)
|
|
}
|
|
}
|
|
|
|
async function processAsReceipt(
|
|
supabase: Awaited<ReturnType<typeof getSupabase>>,
|
|
itemId: string,
|
|
userId: string,
|
|
extraction: import('@/types').ReceiptExtractionResult
|
|
): Promise<void> {
|
|
console.log(`[invoice-inbox] item=${itemId} processing as receipt: merchant=${extraction.merchant?.name}, total=${extraction.totals?.total}`)
|
|
|
|
// Fetch the inbox item to get document_id for the receipt image_url
|
|
const { data: inboxItem } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('document_id, document:document_attachments(storage_path)')
|
|
.eq('id', itemId)
|
|
.single()
|
|
|
|
let imageUrl: string | null = null
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const document = inboxItem?.document as any
|
|
if (document?.storage_path) {
|
|
const { data: urlData } = supabase.storage
|
|
.from('documents')
|
|
.getPublicUrl(document.storage_path)
|
|
imageUrl = urlData?.publicUrl || null
|
|
}
|
|
|
|
// Create receipt record
|
|
const { data: receipt, error: receiptError } = await supabase
|
|
.from('receipts')
|
|
.insert({
|
|
user_id: userId,
|
|
image_url: imageUrl,
|
|
status: 'extracted',
|
|
extraction_confidence: extraction.confidence,
|
|
merchant_name: extraction.merchant?.name || null,
|
|
merchant_org_number: extraction.merchant?.orgNumber || null,
|
|
merchant_vat_number: extraction.merchant?.vatNumber || null,
|
|
receipt_date: extraction.receipt?.date || null,
|
|
receipt_time: extraction.receipt?.time || null,
|
|
total_amount: extraction.totals?.total || null,
|
|
currency: extraction.receipt?.currency || 'SEK',
|
|
vat_amount: extraction.totals?.vatAmount || null,
|
|
is_restaurant: extraction.flags?.isRestaurant || false,
|
|
is_systembolaget: extraction.flags?.isSystembolaget || false,
|
|
is_foreign_merchant: extraction.flags?.isForeignMerchant || false,
|
|
raw_extraction: extraction,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (receiptError || !receipt) {
|
|
console.error(`[invoice-inbox] item=${itemId} receipt creation failed:`, receiptError)
|
|
// Fall back: store as receipt type without linked receipt
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
status: 'ready',
|
|
document_type: 'receipt',
|
|
extracted_data: extraction as unknown as Record<string, unknown>,
|
|
confidence: extraction.confidence,
|
|
})
|
|
.eq('id', itemId)
|
|
return
|
|
}
|
|
|
|
// Insert receipt line items
|
|
if (extraction.lineItems?.length > 0) {
|
|
const lineItemsToInsert = extraction.lineItems.map((item, index) => ({
|
|
receipt_id: receipt.id,
|
|
description: item.description || '',
|
|
quantity: item.quantity || 1,
|
|
unit_price: item.unitPrice,
|
|
line_total: item.lineTotal || 0,
|
|
vat_rate: item.vatRate,
|
|
vat_amount:
|
|
item.vatRate && item.lineTotal
|
|
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
|
|
: null,
|
|
suggested_category: item.suggestedCategory || null,
|
|
sort_order: index,
|
|
}))
|
|
|
|
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
|
|
}
|
|
|
|
// Update inbox item: mark as receipt with linked receipt
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
status: 'ready',
|
|
document_type: 'receipt',
|
|
extracted_data: extraction as unknown as Record<string, unknown>,
|
|
confidence: extraction.confidence,
|
|
linked_receipt_id: receipt.id,
|
|
})
|
|
.eq('id', itemId)
|
|
|
|
// Emit receipt.extracted event (non-blocking)
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'receipt.extracted',
|
|
payload: {
|
|
receipt,
|
|
documentId: inboxItem?.document_id || null,
|
|
confidence: extraction.confidence,
|
|
userId,
|
|
},
|
|
})
|
|
} catch {
|
|
// Non-blocking
|
|
}
|
|
|
|
// Try to match receipt to a transaction
|
|
try {
|
|
const { data: updatedItem } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*')
|
|
.eq('id', itemId)
|
|
.single()
|
|
|
|
if (updatedItem) {
|
|
const matchResult = await matchDocumentToTransactions(
|
|
supabase,
|
|
userId,
|
|
updatedItem as InvoiceInboxItem
|
|
)
|
|
if (matchResult) {
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
matched_transaction_id: matchResult.transactionId,
|
|
match_confidence: matchResult.confidence,
|
|
match_method: matchResult.method,
|
|
})
|
|
.eq('id', itemId)
|
|
}
|
|
}
|
|
} catch (matchError) {
|
|
console.error('[invoice-inbox] Receipt transaction matching failed:', matchError)
|
|
}
|
|
}
|
|
|
|
async function processAsInvoice(
|
|
supabase: Awaited<ReturnType<typeof getSupabase>>,
|
|
itemId: string,
|
|
userId: string,
|
|
extraction: InvoiceExtractionResult
|
|
): Promise<void> {
|
|
console.log(`[invoice-inbox] item=${itemId} processing as supplier_invoice: supplier=${extraction.supplier?.name}, total=${extraction.totals?.total}`)
|
|
|
|
// Supplier matching
|
|
const settings = await getSettings(userId)
|
|
let matchedSupplierId: string | null = null
|
|
|
|
if (settings.autoMatchSupplierEnabled) {
|
|
const { data: suppliers } = await supabase
|
|
.from('suppliers')
|
|
.select('*')
|
|
.eq('user_id', userId)
|
|
|
|
if (suppliers && suppliers.length > 0) {
|
|
const match = matchSupplier(extraction, suppliers)
|
|
if (match && match.confidence >= settings.supplierMatchThreshold) {
|
|
matchedSupplierId = match.supplierId
|
|
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store extraction result with template suggestion
|
|
const updateData: Record<string, unknown> = {
|
|
status: 'ready',
|
|
document_type: 'supplier_invoice',
|
|
extracted_data: extraction as unknown as Record<string, unknown>,
|
|
confidence: extraction.confidence,
|
|
matched_supplier_id: matchedSupplierId,
|
|
}
|
|
|
|
if (extraction.suggestedTemplateId) {
|
|
updateData.suggested_template_id = extraction.suggestedTemplateId
|
|
updateData.suggested_template_confidence = extraction.confidence
|
|
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
|
|
}
|
|
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update(updateData)
|
|
.eq('id', itemId)
|
|
|
|
// Fetch the updated item for event emission and matching
|
|
const { data: updatedItem } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*')
|
|
.eq('id', itemId)
|
|
.single()
|
|
|
|
if (updatedItem) {
|
|
await eventBus.emit({
|
|
type: 'supplier_invoice.extracted',
|
|
payload: {
|
|
inboxItem: updatedItem,
|
|
confidence: extraction.confidence,
|
|
userId,
|
|
},
|
|
})
|
|
|
|
// Document-to-transaction matching
|
|
try {
|
|
const matchResult = await matchDocumentToTransactions(
|
|
supabase,
|
|
userId,
|
|
updatedItem as InvoiceInboxItem
|
|
)
|
|
|
|
if (matchResult) {
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
matched_transaction_id: matchResult.transactionId,
|
|
match_confidence: matchResult.confidence,
|
|
match_method: matchResult.method,
|
|
})
|
|
.eq('id', itemId)
|
|
}
|
|
} catch (matchError) {
|
|
console.error('[invoice-inbox] Transaction matching failed:', matchError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// GET /inbox/:id — Get single inbox item
|
|
// ============================================================
|
|
|
|
async function handleGetInboxItem(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const supabase = await getSupabase()
|
|
const id = getIdParam(request)
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 })
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name)')
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (error || !data) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
}
|
|
|
|
// ============================================================
|
|
// PATCH /inbox/:id — Update inbox item fields
|
|
// ============================================================
|
|
|
|
async function handlePatchInboxItem(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const supabase = await getSupabase()
|
|
const id = getIdParam(request)
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 })
|
|
}
|
|
|
|
const body = await request.json()
|
|
|
|
// Verify item exists and belongs to user
|
|
const { data: existing, error: findError } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('id, status')
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (findError || !existing) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
}
|
|
|
|
if (existing.status === 'confirmed') {
|
|
return NextResponse.json({ error: 'Cannot edit confirmed item' }, { status: 400 })
|
|
}
|
|
|
|
// Only allow updating certain fields
|
|
const allowedFields: Record<string, unknown> = {}
|
|
if (body.extracted_data !== undefined) allowedFields.extracted_data = body.extracted_data
|
|
if (body.matched_supplier_id !== undefined) allowedFields.matched_supplier_id = body.matched_supplier_id
|
|
|
|
if (Object.keys(allowedFields).length === 0) {
|
|
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
|
}
|
|
|
|
const { data, error } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.update(allowedFields)
|
|
.eq('id', id)
|
|
.select()
|
|
.single()
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: error.message }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
}
|
|
|
|
// ============================================================
|
|
// DELETE /inbox/:id — Soft-delete (reject) inbox item
|
|
// ============================================================
|
|
|
|
async function handleDeleteInboxItem(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const supabase = await getSupabase()
|
|
const id = getIdParam(request)
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 })
|
|
}
|
|
|
|
// Soft delete: set status to rejected
|
|
const { data, error } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({ status: 'rejected' })
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
.select()
|
|
.single()
|
|
|
|
if (error || !data) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /inbox/:id/process — Re-process an inbox item
|
|
// ============================================================
|
|
|
|
async function handleProcessInboxItem(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const supabase = await getSupabase()
|
|
const id = getIdParam(request)
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 })
|
|
}
|
|
|
|
// Fetch inbox item
|
|
const { data: inboxItem, error: findError } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*, document:document_attachments(id, storage_path, mime_type)')
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (findError || !inboxItem) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
}
|
|
|
|
if (inboxItem.status === 'confirmed') {
|
|
return NextResponse.json({ error: 'Already confirmed' }, { status: 400 })
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const document = inboxItem.document as any
|
|
if (!document?.storage_path || !document?.mime_type) {
|
|
return NextResponse.json({ error: 'No document attached' }, { status: 400 })
|
|
}
|
|
|
|
// Update status to processing
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({ status: 'processing', error_message: null })
|
|
.eq('id', id)
|
|
|
|
try {
|
|
// Download file
|
|
const { data: fileData, error: downloadError } = await supabase.storage
|
|
.from('documents')
|
|
.download(document.storage_path)
|
|
|
|
if (downloadError || !fileData) {
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({ status: 'error', error_message: 'Failed to download document' })
|
|
.eq('id', id)
|
|
return NextResponse.json({ error: 'Failed to download document' }, { status: 500 })
|
|
}
|
|
|
|
const arrayBuffer = await fileData.arrayBuffer()
|
|
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
|
|
|
// Reset previous classification on re-process
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
matched_transaction_id: null,
|
|
match_confidence: null,
|
|
match_method: null,
|
|
linked_receipt_id: null,
|
|
})
|
|
.eq('id', id)
|
|
|
|
// Analyze with classification
|
|
const result = await analyzeDocument(base64, document.mime_type)
|
|
const { classification } = result
|
|
|
|
console.log(`[invoice-inbox] Re-process item=${id} classified as: ${classification.type} (confidence=${classification.confidence})`)
|
|
|
|
if (classification.type === 'receipt' && result.receipt) {
|
|
await processAsReceipt(supabase, id, userId, result.receipt)
|
|
} else if (classification.type === 'supplier_invoice' && result.invoice) {
|
|
await processAsInvoice(supabase, id, userId, result.invoice)
|
|
} else if (classification.type === 'government_letter' || classification.type === 'unknown') {
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
status: 'ready',
|
|
document_type: classification.type,
|
|
confidence: classification.confidence,
|
|
error_message: null,
|
|
})
|
|
.eq('id', id)
|
|
} else {
|
|
// Fallback: extract as invoice
|
|
const { extractInvoice } = await import('@/lib/ai/document-analyzer')
|
|
const fallbackExtraction = await extractInvoice(base64, document.mime_type)
|
|
await processAsInvoice(supabase, id, userId, fallbackExtraction)
|
|
}
|
|
|
|
// Fetch final state
|
|
const { data: updatedItem, error: updateError } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.single()
|
|
|
|
if (updateError) {
|
|
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data: updatedItem })
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Processing failed'
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({ status: 'error', error_message: message })
|
|
.eq('id', id)
|
|
return NextResponse.json({ error: message }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /inbox/:id/confirm — Confirm inbox item as supplier invoice
|
|
// ============================================================
|
|
|
|
async function handleConfirmInboxItem(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const supabase = await getSupabase()
|
|
const id = getIdParam(request)
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 })
|
|
}
|
|
|
|
// Fetch inbox item
|
|
const { data: inboxItem, error: findError } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (findError || !inboxItem) {
|
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
}
|
|
|
|
if (inboxItem.status === 'confirmed') {
|
|
return NextResponse.json({ error: 'Already confirmed' }, { status: 400 })
|
|
}
|
|
|
|
if (!inboxItem.extracted_data) {
|
|
return NextResponse.json({ error: 'No extracted data available' }, { status: 400 })
|
|
}
|
|
|
|
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
|
|
const body = await request.json().catch(() => ({}))
|
|
|
|
try {
|
|
console.log('[invoice-inbox] Confirm: starting for item', id, 'extraction keys:', Object.keys(extraction))
|
|
|
|
// Resolve supplier: use matched, use body override, or create new
|
|
let supplierId = body.supplier_id || inboxItem.matched_supplier_id
|
|
|
|
if (!supplierId) {
|
|
// Use frontend-provided supplier data if available, otherwise fall back to extraction
|
|
const frontendSupplier = body.new_supplier as {
|
|
name?: string
|
|
supplier_type?: string
|
|
org_number?: string
|
|
vat_number?: string
|
|
bankgiro?: string
|
|
plusgiro?: string
|
|
default_expense_account?: string
|
|
default_currency?: string
|
|
} | undefined
|
|
|
|
const supplierName = frontendSupplier?.name || extraction.supplier?.name
|
|
if (!supplierName) {
|
|
return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 })
|
|
}
|
|
|
|
const validSupplierTypes = ['swedish_business', 'eu_business', 'non_eu_business']
|
|
const supplierType = frontendSupplier?.supplier_type && validSupplierTypes.includes(frontendSupplier.supplier_type)
|
|
? frontendSupplier.supplier_type
|
|
: 'swedish_business'
|
|
|
|
const { data: newSupplier, error: supplierError } = await supabase
|
|
.from('suppliers')
|
|
.insert({
|
|
user_id: userId,
|
|
name: supplierName,
|
|
supplier_type: supplierType,
|
|
org_number: frontendSupplier?.org_number || extraction.supplier?.orgNumber || null,
|
|
vat_number: frontendSupplier?.vat_number || extraction.supplier?.vatNumber || null,
|
|
bankgiro: frontendSupplier?.bankgiro || extraction.supplier?.bankgiro || null,
|
|
plusgiro: frontendSupplier?.plusgiro || extraction.supplier?.plusgiro || null,
|
|
default_expense_account: frontendSupplier?.default_expense_account || '6200',
|
|
default_payment_terms: 30,
|
|
default_currency: frontendSupplier?.default_currency || extraction.invoice?.currency || 'SEK',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (supplierError || !newSupplier) {
|
|
console.error('[invoice-inbox] Confirm: supplier creation failed:', supplierError)
|
|
return NextResponse.json({ error: 'Failed to create supplier' }, { status: 500 })
|
|
}
|
|
|
|
supplierId = newSupplier.id
|
|
}
|
|
|
|
// Verify supplier exists and belongs to user
|
|
const { data: supplier, error: supplierCheckError } = await supabase
|
|
.from('suppliers')
|
|
.select('*')
|
|
.eq('id', supplierId)
|
|
.eq('user_id', userId)
|
|
.single()
|
|
|
|
if (supplierCheckError || !supplier) {
|
|
console.error('[invoice-inbox] Confirm: supplier check failed:', supplierCheckError, 'supplierId=', supplierId)
|
|
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
|
}
|
|
|
|
// Get next arrival number
|
|
const { data: arrivalNum, error: arrivalError } = await supabase
|
|
.rpc('get_next_arrival_number', { p_user_id: userId })
|
|
|
|
if (arrivalError) {
|
|
console.error('[invoice-inbox] Confirm: arrival number error:', arrivalError)
|
|
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
|
}
|
|
|
|
console.log('[invoice-inbox] Confirm: supplierId=', supplierId, 'arrivalNum=', arrivalNum)
|
|
|
|
// Build line items from extraction.
|
|
// The AI extraction may return lineTotal as gross (incl. VAT) or net.
|
|
// Cross-check with extraction.totals to determine the correct split.
|
|
const extractedTotals = extraction.totals
|
|
const rawLineItems = extraction.lineItems || []
|
|
|
|
const items: Array<{
|
|
sort_order: number
|
|
description: string
|
|
quantity: number
|
|
unit: string
|
|
unit_price: number
|
|
line_total: number
|
|
account_number: string
|
|
vat_code: null
|
|
vat_rate: number
|
|
vat_amount: number
|
|
}> = []
|
|
|
|
if (rawLineItems.length > 0 && extractedTotals?.subtotal != null && extractedTotals?.vatAmount != null) {
|
|
// We have both line items and reliable totals — use totals for amounts
|
|
// and distribute proportionally across line items
|
|
const rawSum = rawLineItems.reduce((s, i) => s + (i.lineTotal || 0), 0)
|
|
const knownSubtotal = extractedTotals.subtotal
|
|
const knownVat = extractedTotals.vatAmount
|
|
const knownVatRate = knownSubtotal > 0
|
|
? Math.round((knownVat / knownSubtotal) * 100) / 100
|
|
: 0.25
|
|
|
|
for (let index = 0; index < rawLineItems.length; index++) {
|
|
const item = rawLineItems[index]
|
|
// Distribute the known subtotal proportionally by each line's share
|
|
const proportion = rawSum > 0 ? (item.lineTotal || 0) / rawSum : 1 / rawLineItems.length
|
|
const lineNet = Math.round(knownSubtotal * proportion * 100) / 100
|
|
const lineVat = Math.round(knownVat * proportion * 100) / 100
|
|
items.push({
|
|
sort_order: index,
|
|
description: item.description,
|
|
quantity: item.quantity || 1,
|
|
unit: 'st',
|
|
unit_price: lineNet,
|
|
line_total: lineNet,
|
|
account_number: item.accountSuggestion || supplier.default_expense_account || '6200',
|
|
vat_code: null,
|
|
vat_rate: knownVatRate,
|
|
vat_amount: lineVat,
|
|
})
|
|
}
|
|
} else if (rawLineItems.length > 0) {
|
|
// Line items but no reliable totals — use per-line vatRate
|
|
for (let index = 0; index < rawLineItems.length; index++) {
|
|
const item = rawLineItems[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
|
|
items.push({
|
|
sort_order: index,
|
|
description: item.description,
|
|
quantity: item.quantity || 1,
|
|
unit: 'st',
|
|
unit_price: item.unitPrice != null ? item.unitPrice : lineTotal,
|
|
line_total: lineTotal,
|
|
account_number: item.accountSuggestion || supplier.default_expense_account || '6200',
|
|
vat_code: null,
|
|
vat_rate: vatRate,
|
|
vat_amount: vatAmount,
|
|
})
|
|
}
|
|
}
|
|
|
|
// If no line items, create a single item from totals
|
|
if (items.length === 0 && extractedTotals?.total) {
|
|
const total = extractedTotals.total
|
|
const vatAmt = extractedTotals.vatAmount || 0
|
|
const sub = extractedTotals.subtotal || total - vatAmt
|
|
const vatRate = sub > 0 ? Math.round((vatAmt / sub) * 100) / 100 : 0.25
|
|
items.push({
|
|
sort_order: 0,
|
|
description: 'Fakturabelopp',
|
|
quantity: 1,
|
|
unit: 'st',
|
|
unit_price: sub,
|
|
line_total: sub,
|
|
account_number: supplier.default_expense_account || '6200',
|
|
vat_code: null,
|
|
vat_rate: vatRate,
|
|
vat_amount: Math.round(vatAmt * 100) / 100,
|
|
})
|
|
}
|
|
|
|
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
|
|
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
|
|
const total = Math.round((subtotal + vatAmount) * 100) / 100
|
|
|
|
// Determine VAT treatment
|
|
const primaryVatRate = items[0]?.vat_rate || 0.25
|
|
let vatTreatment = 'standard_25'
|
|
if (primaryVatRate === 0.12) vatTreatment = 'reduced_12'
|
|
else if (primaryVatRate === 0.06) vatTreatment = 'reduced_6'
|
|
else if (primaryVatRate === 0) vatTreatment = 'exempt'
|
|
|
|
console.log('[invoice-inbox] Confirm: items=', items.length, 'subtotal=', subtotal, 'vat=', vatAmount, 'total=', total, 'vatTreatment=', vatTreatment)
|
|
|
|
// Insert supplier invoice
|
|
const { data: invoice, error: invoiceError } = await supabase
|
|
.from('supplier_invoices')
|
|
.insert({
|
|
user_id: userId,
|
|
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],
|
|
status: 'registered',
|
|
currency: extraction.invoice?.currency || 'SEK',
|
|
vat_treatment: vatTreatment,
|
|
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,
|
|
remaining_amount: Math.round(total * 100) / 100,
|
|
document_id: inboxItem.document_id || null,
|
|
notes: body.notes || null,
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (invoiceError || !invoice) {
|
|
console.error('[invoice-inbox] Confirm: invoice insert error:', invoiceError)
|
|
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
|
|
}
|
|
|
|
console.log('[invoice-inbox] Confirm: invoice created id=', invoice.id)
|
|
|
|
// Insert line items
|
|
const itemInserts = items.map((item) => ({
|
|
supplier_invoice_id: invoice.id,
|
|
...item,
|
|
}))
|
|
|
|
const { error: itemsError } = await supabase
|
|
.from('supplier_invoice_items')
|
|
.insert(itemInserts)
|
|
|
|
if (itemsError) {
|
|
console.error('[invoice-inbox] Confirm: items insert error:', itemsError, 'payload:', JSON.stringify(itemInserts))
|
|
await supabase.from('supplier_invoices').delete().eq('id', invoice.id)
|
|
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
|
}
|
|
|
|
// Update inbox item as confirmed
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({
|
|
status: 'confirmed',
|
|
matched_supplier_id: supplierId,
|
|
created_supplier_invoice_id: invoice.id,
|
|
})
|
|
.eq('id', inboxItem.id)
|
|
|
|
// Emit confirmed event
|
|
try {
|
|
await eventBus.emit({
|
|
type: 'supplier_invoice.confirmed',
|
|
payload: {
|
|
inboxItem: { ...inboxItem, status: 'confirmed' },
|
|
supplierInvoice: invoice as SupplierInvoice,
|
|
userId,
|
|
},
|
|
})
|
|
} catch {
|
|
// Non-blocking
|
|
}
|
|
|
|
// Journal entry creation is handled asynchronously by the core
|
|
// supplier_invoice.confirmed event handler (see lib/bookkeeping/handlers/)
|
|
return NextResponse.json({
|
|
data: {
|
|
...invoice,
|
|
items: itemInserts,
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('[invoice-inbox] Confirm failed:', error instanceof Error ? error.stack : error)
|
|
return NextResponse.json({ error: 'Confirmation failed' }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// POST /inbox/:id/confirm-receipt — Confirm inbox item as receipt
|
|
// ============================================================
|
|
|
|
async function handleConfirmReceipt(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const supabase = await getSupabase()
|
|
const id = getIdParam(request)
|
|
|
|
if (!id) {
|
|
return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 })
|
|
}
|
|
|
|
// Fetch inbox item
|
|
const { data: inboxItem, error: findError } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.eq('user_id', userId)
|
|
.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', userId)
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
})
|
|
}
|
|
} catch {
|
|
// Non-blocking
|
|
}
|
|
|
|
return NextResponse.json({ data: { confirmed: true, businessTotal, privateTotal } })
|
|
}
|
|
|
|
// ============================================================
|
|
// GET /settings — Get extension settings
|
|
// ============================================================
|
|
|
|
async function handleGetSettings(
|
|
_request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const settings = await getSettings(userId)
|
|
return NextResponse.json({ data: settings })
|
|
}
|
|
|
|
// ============================================================
|
|
// PUT /settings — Update extension settings
|
|
// ============================================================
|
|
|
|
async function handlePutSettings(
|
|
request: Request,
|
|
ctx?: ExtensionContext
|
|
): Promise<Response> {
|
|
const userId = ctx!.userId
|
|
const body = await request.json()
|
|
const settings = await saveSettings(userId, body)
|
|
return NextResponse.json({ data: settings })
|
|
}
|
|
|
|
// ============================================================
|
|
// Route definitions
|
|
// ============================================================
|
|
|
|
export const invoiceInboxApiRoutes: ApiRouteDefinition[] = [
|
|
// /inbox
|
|
{
|
|
method: 'GET',
|
|
path: '/inbox',
|
|
handler: handleGetInbox,
|
|
},
|
|
{
|
|
method: 'POST',
|
|
path: '/inbox',
|
|
handler: handlePostInbox,
|
|
},
|
|
// /inbox/:id
|
|
{
|
|
method: 'GET',
|
|
path: '/inbox/:id',
|
|
handler: handleGetInboxItem,
|
|
},
|
|
{
|
|
method: 'PATCH',
|
|
path: '/inbox/:id',
|
|
handler: handlePatchInboxItem,
|
|
},
|
|
{
|
|
method: 'DELETE',
|
|
path: '/inbox/:id',
|
|
handler: handleDeleteInboxItem,
|
|
},
|
|
// /inbox/:id/process
|
|
{
|
|
method: 'POST',
|
|
path: '/inbox/:id/process',
|
|
handler: handleProcessInboxItem,
|
|
},
|
|
// /inbox/:id/confirm
|
|
{
|
|
method: 'POST',
|
|
path: '/inbox/:id/confirm',
|
|
handler: handleConfirmInboxItem,
|
|
},
|
|
// /inbox/:id/confirm-receipt
|
|
{
|
|
method: 'POST',
|
|
path: '/inbox/:id/confirm-receipt',
|
|
handler: handleConfirmReceipt,
|
|
},
|
|
// /settings
|
|
{
|
|
method: 'GET',
|
|
path: '/settings',
|
|
handler: handleGetSettings,
|
|
},
|
|
{
|
|
method: 'PUT',
|
|
path: '/settings',
|
|
handler: handlePutSettings,
|
|
},
|
|
]
|