Files
accounted/extensions/general/receipt-ocr/lib/receipt-pipeline.ts
T
Jakob WennbergandClaude Opus 4.6 3f255ea201 refactor: consolidate AI analyzers into shared lib/ai module and add journal entry reversal columns
Extract shared vision/document analysis logic into lib/ai (vision-client, document-analyzer,
image preprocessing, validation helpers), simplifying invoice-analyzer, receipt-analyzer, and
document classifier. Add migration 046 for journal entry reversal/correction link columns
(reversed_by_id, reverses_id, correction_of_id) required by storno service. Update extension
components and shared UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:14:44 +01:00

171 lines
5.6 KiB
TypeScript

/**
* Receipt Pipeline — shared, reusable receipt processing function.
*
* SERVER-ONLY: uses receipt-analyzer (Anthropic SDK).
*
* Extracts receipt data, categorizes line items, inserts records,
* and attempts auto-matching against bank transactions.
*/
import 'server-only'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Receipt, ReceiptMatchCandidate, ReceiptExtractionResult } from '@/types'
import { analyzeReceipt } from './receipt-analyzer'
import { processLineItems, getDefaultClassification } from './receipt-categorizer'
import { autoMatchReceipts } from './receipt-matcher'
import { eventBus } from '@/lib/events/bus'
export interface ReceiptPipelineOptions {
documentId: string | null
source: 'upload' | 'camera' | 'email'
emailFrom?: string
storageUrl: string
preExtracted?: ReceiptExtractionResult
}
export interface ProcessedReceipt {
receipt: Receipt
lineItems: unknown[]
matchedTransaction?: { transactionId: string; confidence: number }
}
/**
* Process a document through the receipt pipeline:
* 1. Analyze with Claude Vision
* 2. Categorize line items
* 3. Insert receipt + line items
* 4. Auto-match against unmatched transactions
* 5. Emit receipt.extracted event
*/
export async function processReceiptFromDocument(
supabase: SupabaseClient,
userId: string,
base64: string,
mimeType: string,
opts: ReceiptPipelineOptions
): Promise<ProcessedReceipt> {
// 1. Use pre-extracted data if available, otherwise analyze with Claude Vision
const validImageType = mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const extraction = opts.preExtracted ?? await analyzeReceipt(base64, validImageType)
// 2. Categorize line items and apply default business classification
const processedLineItems = processLineItems(extraction.lineItems)
const { defaultIsBusiness } = getDefaultClassification(
extraction.flags.isRestaurant,
extraction.flags.isSystembolaget
)
// 3. Insert receipt record
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: opts.storageUrl,
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,
document_id: opts.documentId,
source: opts.source,
email_from: opts.emailFrom ?? null,
})
.select()
.single()
if (insertError || !receipt) {
throw new Error(`Failed to create receipt: ${insertError?.message}`)
}
// 4. 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
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
: null,
extraction_confidence: item.confidence,
suggested_category: item.suggestedCategory,
category: item.category,
bas_account: item.basAccount,
is_business: defaultIsBusiness,
sort_order: index,
}))
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
}
// 5. Auto-match against unmatched expense transactions (±7 days from receipt date)
let matchedTransaction: ProcessedReceipt['matchedTransaction'] | undefined
if (extraction.receipt.date && extraction.totals.total) {
const receiptDate = new Date(extraction.receipt.date)
const dateFrom = new Date(receiptDate)
dateFrom.setDate(dateFrom.getDate() - 7)
const dateTo = new Date(receiptDate)
dateTo.setDate(dateTo.getDate() + 7)
const { data: transactions } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('receipt_id', null)
.lt('amount', 0)
.gte('date', dateFrom.toISOString().split('T')[0])
.lte('date', dateTo.toISOString().split('T')[0])
if (transactions && transactions.length > 0) {
const matches = autoMatchReceipts([receipt], transactions, 0.8)
if (matches.length > 0) {
const best = matches[0]
matchedTransaction = {
transactionId: best.match.transaction.id,
confidence: best.match.confidence,
}
// Link receipt to transaction
await supabase
.from('receipts')
.update({
matched_transaction_id: best.match.transaction.id,
match_confidence: best.match.confidence,
})
.eq('id', receipt.id)
await supabase
.from('transactions')
.update({ receipt_id: receipt.id })
.eq('id', best.match.transaction.id)
}
}
}
// 6. Emit event
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt,
documentId: opts.documentId,
confidence: extraction.confidence,
userId,
},
})
return { receipt, lineItems: processedLineItems, matchedTransaction }
}