206 lines
6.4 KiB
TypeScript
206 lines
6.4 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
|
|
import { processLineItems } from '@/lib/receipts/receipt-categorizer'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
|
|
ensureInitialized()
|
|
|
|
/**
|
|
* POST /api/receipts/upload
|
|
* Upload a receipt image and extract data using Claude Vision
|
|
*
|
|
* Accepts multipart/form-data with:
|
|
* - image: The receipt image file
|
|
*/
|
|
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 })
|
|
}
|
|
|
|
try {
|
|
const formData = await request.formData()
|
|
const imageFile = formData.get('image') as File | null
|
|
|
|
if (!imageFile) {
|
|
return NextResponse.json({ error: 'No image file provided' }, { status: 400 })
|
|
}
|
|
|
|
// Validate file type
|
|
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
|
if (!validTypes.includes(imageFile.type)) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Convert file to base64
|
|
const arrayBuffer = await imageFile.arrayBuffer()
|
|
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
|
|
|
// Generate unique filename
|
|
const ext = imageFile.type.split('/')[1]
|
|
const filename = `${user.id}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
|
|
|
|
// Upload to Supabase Storage
|
|
const { data: uploadData, error: uploadError } = await supabase.storage
|
|
.from('receipts')
|
|
.upload(filename, arrayBuffer, {
|
|
contentType: imageFile.type,
|
|
cacheControl: '3600',
|
|
})
|
|
|
|
if (uploadError) {
|
|
console.error('Storage upload error:', uploadError)
|
|
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
|
|
}
|
|
|
|
// Get public URL
|
|
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
|
|
const imageUrl = urlData.publicUrl
|
|
|
|
// Create receipt record with pending status
|
|
const { data: receipt, error: insertError } = await supabase
|
|
.from('receipts')
|
|
.insert({
|
|
user_id: user.id,
|
|
image_url: imageUrl,
|
|
status: 'processing',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (insertError) {
|
|
console.error('Receipt insert error:', insertError)
|
|
return NextResponse.json({ error: 'Failed to create receipt record' }, { status: 500 })
|
|
}
|
|
|
|
// Analyze receipt with Claude Vision
|
|
try {
|
|
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
|
const extraction = await analyzeReceipt(base64, mimeType)
|
|
|
|
// Process and categorize line items
|
|
const processedLineItems = processLineItems(extraction.lineItems)
|
|
|
|
// Update receipt with extracted data
|
|
const { error: updateError } = await supabase
|
|
.from('receipts')
|
|
.update({
|
|
status: 'extracted',
|
|
extraction_confidence: extraction.confidence,
|
|
merchant_name: extraction.merchant.name,
|
|
merchant_org_number: extraction.merchant.orgNumber,
|
|
merchant_vat_number: extraction.merchant.vatNumber,
|
|
receipt_date: extraction.receipt.date,
|
|
receipt_time: extraction.receipt.time,
|
|
total_amount: extraction.totals.total,
|
|
currency: extraction.receipt.currency,
|
|
vat_amount: extraction.totals.vatAmount,
|
|
is_restaurant: extraction.flags.isRestaurant,
|
|
is_systembolaget: extraction.flags.isSystembolaget,
|
|
is_foreign_merchant: extraction.flags.isForeignMerchant,
|
|
raw_extraction: extraction,
|
|
})
|
|
.eq('id', receipt.id)
|
|
|
|
if (updateError) {
|
|
console.error('Receipt update error:', updateError)
|
|
}
|
|
|
|
// Insert line items
|
|
if (processedLineItems.length > 0) {
|
|
const lineItemsToInsert = processedLineItems.map((item, index) => ({
|
|
receipt_id: receipt.id,
|
|
description: item.description,
|
|
quantity: item.quantity,
|
|
unit_price: item.unitPrice,
|
|
line_total: item.lineTotal,
|
|
vat_rate: item.vatRate,
|
|
vat_amount: item.vatRate && item.lineTotal ? (item.lineTotal * item.vatRate) / (100 + item.vatRate) : null,
|
|
extraction_confidence: item.confidence,
|
|
suggested_category: item.suggestedCategory,
|
|
sort_order: index,
|
|
}))
|
|
|
|
const { error: lineItemsError } = await supabase
|
|
.from('receipt_line_items')
|
|
.insert(lineItemsToInsert)
|
|
|
|
if (lineItemsError) {
|
|
console.error('Line items insert error:', lineItemsError)
|
|
}
|
|
}
|
|
|
|
// Fetch the complete receipt with line items
|
|
const { data: completeReceipt, error: fetchError } = await supabase
|
|
.from('receipts')
|
|
.select(`
|
|
*,
|
|
line_items:receipt_line_items(*)
|
|
`)
|
|
.eq('id', receipt.id)
|
|
.single()
|
|
|
|
if (fetchError) {
|
|
console.error('Fetch error:', fetchError)
|
|
return NextResponse.json({
|
|
data: {
|
|
id: receipt.id,
|
|
status: 'extracted',
|
|
extraction,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Emit receipt.extracted event
|
|
await eventBus.emit({
|
|
type: 'receipt.extracted',
|
|
payload: {
|
|
receipt: completeReceipt,
|
|
documentId: null,
|
|
confidence: extraction.confidence,
|
|
userId: user.id,
|
|
},
|
|
})
|
|
|
|
return NextResponse.json({ data: completeReceipt })
|
|
} catch (analysisError) {
|
|
console.error('Receipt analysis error:', analysisError)
|
|
|
|
// Update receipt with error status
|
|
await supabase
|
|
.from('receipts')
|
|
.update({
|
|
status: 'error',
|
|
raw_extraction: {
|
|
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
|
|
},
|
|
})
|
|
.eq('id', receipt.id)
|
|
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Failed to analyze receipt',
|
|
receiptId: receipt.id,
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.error('Upload error:', error)
|
|
return NextResponse.json(
|
|
{ error: error instanceof Error ? error.message : 'Upload failed' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
}
|