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:
Jakob Wennberg
2026-02-25 16:59:02 +01:00
co-authored by Claude Opus 4.6
parent 6956a757f3
commit 39e407644d
68 changed files with 19234 additions and 2412 deletions
@@ -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 })
+151 -71
View File
@@ -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'
+128 -29
View File
@@ -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)
}
}