This commit is contained in:
Jakob Wennberg
2026-02-19 14:32:54 +01:00
parent cdf1dcc4c8
commit e8743e6e03
104 changed files with 4551 additions and 4172 deletions
@@ -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,
},
},
})
}
+55
View File
@@ -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 }
)
}
}