New base
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { ConfirmReceiptInput, Receipt, ReceiptLineItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/receipts/[id]/confirm
|
||||
* Confirm line item classifications and optionally link to a transaction
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership
|
||||
const { data: receipt, error: fetchError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: ConfirmReceiptInput = await request.json()
|
||||
|
||||
// Update line items with classifications
|
||||
if (body.line_items && body.line_items.length > 0) {
|
||||
for (const item of body.line_items) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('receipt_line_items')
|
||||
.update({
|
||||
is_business: item.is_business,
|
||||
category: item.category || null,
|
||||
bas_account: item.bas_account || null,
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('receipt_id', id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Line item update error:', updateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build receipt update
|
||||
const receiptUpdate: Record<string, unknown> = {
|
||||
status: 'confirmed',
|
||||
}
|
||||
|
||||
// Add restaurant representation data if provided
|
||||
if (body.representation_persons !== undefined) {
|
||||
receiptUpdate.representation_persons = body.representation_persons
|
||||
}
|
||||
if (body.representation_purpose !== undefined) {
|
||||
receiptUpdate.representation_purpose = body.representation_purpose
|
||||
}
|
||||
|
||||
// Link to transaction if provided
|
||||
if (body.matched_transaction_id) {
|
||||
// Verify transaction ownership
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('id', body.matched_transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!txError && transaction) {
|
||||
receiptUpdate.matched_transaction_id = body.matched_transaction_id
|
||||
|
||||
// Also update the transaction with the receipt link
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: id })
|
||||
.eq('id', body.matched_transaction_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the receipt
|
||||
const { data: updatedReceipt, error: updateError } = await supabase
|
||||
.from('receipts')
|
||||
.update(receiptUpdate)
|
||||
.eq('id', id)
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Receipt update error:', updateError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate business/private totals from line items
|
||||
const lineItems = ((updatedReceipt as unknown as Receipt).line_items || []) as ReceiptLineItem[]
|
||||
let businessTotal = 0
|
||||
let privateTotal = 0
|
||||
for (const item of lineItems) {
|
||||
if (item.is_business === true) {
|
||||
businessTotal += item.line_total
|
||||
} else if (item.is_business === false) {
|
||||
privateTotal += item.line_total
|
||||
}
|
||||
}
|
||||
|
||||
// Emit receipt.confirmed event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.confirmed',
|
||||
payload: {
|
||||
receipt: updatedReceipt as unknown as Receipt,
|
||||
businessTotal: Math.round(businessTotal * 100) / 100,
|
||||
privateTotal: Math.round(privateTotal * 100) / 100,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: updatedReceipt })
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findTransactionMatches } from '@/extensions/receipt-ocr/lib/receipt-matcher'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Receipt, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/receipts/[id]/match
|
||||
* Find potential transaction matches for a receipt
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch receipt
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get date range for transaction search (±7 days from receipt date)
|
||||
const receiptDate = receipt.receipt_date ? new Date(receipt.receipt_date) : new Date()
|
||||
const startDate = new Date(receiptDate)
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
const endDate = new Date(receiptDate)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
|
||||
// Fetch unmatched transactions in date range
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.is('receipt_id', null)
|
||||
.lt('amount', 0) // Only expenses
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
.lte('date', endDate.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError) {
|
||||
console.error('Transaction fetch error:', txError)
|
||||
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Find matches
|
||||
const matches = findTransactionMatches(
|
||||
receipt as unknown as Receipt,
|
||||
transactions as Transaction[]
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
matches: matches.slice(0, 5), // Return top 5 matches
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/receipts/[id]/match
|
||||
* Link a receipt to a specific transaction
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id, match_confidence } = body
|
||||
|
||||
if (!transaction_id) {
|
||||
return NextResponse.json({ error: 'transaction_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*, line_items:receipt_line_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify transaction ownership
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update receipt with match
|
||||
const { error: updateReceiptError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: transaction_id,
|
||||
match_confidence: match_confidence || null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateReceiptError) {
|
||||
console.error('Receipt update error:', updateReceiptError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Update transaction with receipt link
|
||||
const { error: updateTxError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: id })
|
||||
.eq('id', transaction_id)
|
||||
|
||||
if (updateTxError) {
|
||||
console.error('Transaction update error:', updateTxError)
|
||||
}
|
||||
|
||||
// Emit receipt.matched event
|
||||
await eventBus.emit({
|
||||
type: 'receipt.matched',
|
||||
payload: {
|
||||
receipt: receipt as unknown as Receipt,
|
||||
transaction: transaction as Transaction,
|
||||
confidence: match_confidence || 0,
|
||||
autoMatched: false,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
transaction_id,
|
||||
matched: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/receipts/[id]/match
|
||||
* Unlink a receipt from its matched transaction
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership and get current transaction link
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('id, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const transactionId = receipt.matched_transaction_id
|
||||
|
||||
// Remove match from receipt
|
||||
const { error: updateReceiptError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateReceiptError) {
|
||||
console.error('Receipt update error:', updateReceiptError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Remove receipt link from transaction
|
||||
if (transactionId) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: null })
|
||||
.eq('id', transactionId)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
unmatched: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts/[id]
|
||||
* Get a single receipt with line items
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*),
|
||||
matched_transaction:transactions(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/receipts/[id]
|
||||
* Update a receipt
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Allowed update fields
|
||||
const allowedFields = [
|
||||
'merchant_name',
|
||||
'receipt_date',
|
||||
'receipt_time',
|
||||
'total_amount',
|
||||
'currency',
|
||||
'vat_amount',
|
||||
'is_restaurant',
|
||||
'is_systembolaget',
|
||||
'is_foreign_merchant',
|
||||
'representation_persons',
|
||||
'representation_purpose',
|
||||
'status',
|
||||
]
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
updates[field] = body[field]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('receipts')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/receipts/[id]
|
||||
* Delete a receipt and its line items
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get receipt to find image URL for cleanup
|
||||
const { data: receipt } = await supabase
|
||||
.from('receipts')
|
||||
.select('image_url, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Unlink from transaction if matched
|
||||
if (receipt.matched_transaction_id) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: null })
|
||||
.eq('id', receipt.matched_transaction_id)
|
||||
}
|
||||
|
||||
// Delete receipt (line items are cascade deleted)
|
||||
const { error } = await supabase
|
||||
.from('receipts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Optionally delete image from storage
|
||||
if (receipt.image_url) {
|
||||
try {
|
||||
const urlParts = receipt.image_url.split('/receipts/')
|
||||
if (urlParts[1]) {
|
||||
await supabase.storage.from('receipts').remove([urlParts[1]])
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts/queue
|
||||
* Get unmatched receipts and queue statistics
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status') // 'pending', 'extracted', 'confirmed', or null for all
|
||||
const unmatched = searchParams.get('unmatched') === 'true'
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (unmatched) {
|
||||
query = query.is('matched_transaction_id', null)
|
||||
}
|
||||
|
||||
const { data: receipts, error: receiptsError } = await query
|
||||
|
||||
if (receiptsError) {
|
||||
console.error('Receipts fetch error:', receiptsError)
|
||||
return NextResponse.json({ error: 'Failed to fetch receipts' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Get counts for queue summary
|
||||
const { count: unmatchedReceiptsCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'confirmed')
|
||||
.is('matched_transaction_id', null)
|
||||
|
||||
const { count: pendingReviewCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'extracted')
|
||||
|
||||
const { count: unmatchedTransactionsCount } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.lt('amount', 0)
|
||||
.is('receipt_id', null)
|
||||
|
||||
// Calculate streak (days with at least one categorized transaction)
|
||||
const { data: recentActivity } = await supabase
|
||||
.from('receipts')
|
||||
.select('created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'confirmed')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30)
|
||||
|
||||
let streakCount = 0
|
||||
if (recentActivity && recentActivity.length > 0) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const activityDates = new Set(
|
||||
recentActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
|
||||
)
|
||||
|
||||
let checkDate = new Date(today)
|
||||
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
|
||||
streakCount++
|
||||
checkDate.setDate(checkDate.getDate() - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipts,
|
||||
summary: {
|
||||
unmatched_receipts_count: unmatchedReceiptsCount || 0,
|
||||
unmatched_transactions_count: unmatchedTransactionsCount || 0,
|
||||
pending_review_count: pendingReviewCount || 0,
|
||||
streak_count: streakCount,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts
|
||||
* List receipts for the authenticated user
|
||||
* Query params:
|
||||
* - status: filter by status
|
||||
* - limit: max results (default 50)
|
||||
* - offset: pagination offset
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
const limit = parseInt(searchParams.get('limit') || '50', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
|
||||
let query = supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`, { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
console.error('Receipts fetch error:', error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
count,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer'
|
||||
import { processLineItems } from '@/extensions/receipt-ocr/lib/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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user