Files
accounted/extensions/general/receipt-ocr/index.ts
T
Jakob WennbergandClaude Opus 4.6 ef5a84a5d5 feat: make extension system packageable via enriched ExtensionContext
Enrich ExtensionContext with supabase, emit(), settings, storage, log,
and services so extensions can receive everything through dependency
injection instead of importing core modules directly.

- Add context factory and inject context into event handlers via registry
- Move supplier invoice journal entry creation to core event handler
- Add services.ingestTransactions to ExtensionContext for enable-banking
- Create catch-all API route for extension-declared apiRoutes
- Migrate 5 extensions to accept context with dynamic import fallbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:24:21 +01:00

335 lines
10 KiB
TypeScript

import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems } from './lib/receipt-categorizer'
import { autoMatchReceipts } from './lib/receipt-matcher'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { Receipt } from '@/types'
// ============================================================
// Settings
// ============================================================
export interface ReceiptOcrSettings {
autoOcrEnabled: boolean
autoMatchEnabled: boolean
autoMatchThreshold: number
ocrConfidenceThreshold: number
}
const DEFAULT_SETTINGS: ReceiptOcrSettings = {
autoOcrEnabled: true,
autoMatchEnabled: true,
autoMatchThreshold: 0.8,
ocrConfidenceThreshold: 0.6,
}
/** Get settings via ExtensionContext (preferred in event handlers) */
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<ReceiptOcrSettings> {
const stored = await ctx.settings.get<Partial<ReceiptOcrSettings>>()
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
}
/** Get settings for external callers (settings routes, API routes) */
export async function getSettings(userId: string): Promise<ReceiptOcrSettings> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('extension_id', 'receipt-ocr')
.eq('key', 'settings')
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
// Merge with defaults for forward-compatibility
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<ReceiptOcrSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<ReceiptOcrSettings>
): Promise<ReceiptOcrSettings> {
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: 'receipt-ocr',
key: 'settings',
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
return merged
}
// ============================================================
// Event Handlers
// ============================================================
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
/**
* When an image is uploaded via the document archive, auto-trigger OCR.
*/
async function handleDocumentUploaded(
payload: EventPayload<'document.uploaded'>,
ctx?: ExtensionContext
): Promise<void> {
const { document, userId } = payload
const log = ctx?.log ?? console
// Gate: Is it an image?
if (!document.mime_type || !IMAGE_MIME_TYPES.includes(document.mime_type)) {
return
}
// Gate: Is autoOcrEnabled?
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoOcrEnabled) {
return
}
log.info(`Auto-OCR triggered for document ${document.id}`)
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Download image from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('documents')
.download(document.storage_path)
if (downloadError || !fileData) {
log.error('Failed to download document:', downloadError)
return
}
// Convert to base64
const arrayBuffer = await fileData.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
const mimeType = document.mime_type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
// Analyze receipt
const extraction = await analyzeReceipt(base64, mimeType)
// Gate: Is confidence high enough?
if (extraction.confidence < settings.ocrConfidenceThreshold) {
log.info(
`Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping`
)
return
}
// Process line items
const processedLineItems = processLineItems(extraction.lineItems)
// Get public URL for the document
const { data: urlData } = supabase.storage
.from('documents')
.getPublicUrl(document.storage_path)
// Create receipt record
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: urlData.publicUrl,
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,
})
.select()
.single()
if (insertError || !receipt) {
log.error('Failed to create receipt:', insertError)
return
}
// 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,
}))
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
}
// Fetch complete receipt with line items
const { data: completeReceipt } = await supabase
.from('receipts')
.select('*, line_items:receipt_line_items(*)')
.eq('id', receipt.id)
.single()
// Emit receipt.extracted
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'receipt.extracted',
payload: {
receipt: (completeReceipt || receipt) as unknown as Receipt,
documentId: document.id,
confidence: extraction.confidence,
userId,
},
})
log.info(`Receipt ${receipt.id} created from document ${document.id}`)
} catch (error) {
log.error('handleDocumentUploaded failed:', error)
}
}
/**
* When new transactions arrive from banking sync, auto-match unmatched receipts.
*/
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>,
ctx?: ExtensionContext
): Promise<void> {
const { transactions: syncedTransactions, userId } = payload
const log = ctx?.log ?? console
// Gate: Is autoMatchEnabled?
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoMatchEnabled) {
return
}
// Only consider expense transactions
const expenseTransactions = syncedTransactions.filter((t) => t.amount < 0)
if (expenseTransactions.length === 0) {
return
}
log.info(`Auto-match triggered for ${expenseTransactions.length} expense transactions`)
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Fetch unmatched receipts
const { data: unmatchedReceipts, error: fetchError } = await supabase
.from('receipts')
.select('*, line_items:receipt_line_items(*)')
.eq('user_id', userId)
.in('status', ['extracted', 'confirmed'])
.is('matched_transaction_id', null)
if (fetchError || !unmatchedReceipts || unmatchedReceipts.length === 0) {
return
}
// Run auto-matching
const matches = autoMatchReceipts(
unmatchedReceipts as unknown as Receipt[],
expenseTransactions,
settings.autoMatchThreshold
)
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
// Process each match
for (const { receipt, match } of matches) {
// Update receipt with match
await supabase
.from('receipts')
.update({
matched_transaction_id: match.transaction.id,
match_confidence: match.confidence,
})
.eq('id', receipt.id)
// Update transaction with receipt link
await supabase
.from('transactions')
.update({ receipt_id: receipt.id })
.eq('id', match.transaction.id)
// Emit receipt.matched
await emit({
type: 'receipt.matched',
payload: {
receipt,
transaction: match.transaction,
confidence: match.confidence,
autoMatched: true,
userId,
},
})
log.info(
`Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})`
)
}
} catch (error) {
log.error('handleTransactionSynced failed:', error)
}
}
// ============================================================
// Extension Object
// ============================================================
export const receiptOcrExtension: Extension = {
id: 'receipt-ocr',
name: 'Receipt OCR',
version: '1.0.0',
eventHandlers: [
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
mappingRuleTypes: [
{
id: 'receipt-ocr-merchant',
name: 'OCR Merchant Match',
description: 'Auto-categorize transactions based on OCR-extracted merchant names',
},
{
id: 'receipt-ocr-category',
name: 'OCR Category Suggestion',
description: 'Suggest transaction categories from receipt line item analysis',
},
],
settingsPanel: {
label: 'Receipt OCR',
path: '/settings/extensions/receipt-ocr',
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}