Files
accounted/extensions/general/receipt-ocr/index.ts
T
Jakob Wennberg 03b569d708 refactor: consolidate extension system to general-only with manifest-driven architecture
- Remove all sector-specific extensions (construction, ecommerce, export,
  hotel, restaurant, tech) — only general-purpose extensions remain
- Move NE-bilaga and SRU export from extensions to core reports (lib/reports/)
- Move moms-box-mapping from extensions/export/shared to lib/vat/
- Replace per-extension API routes with catch-all dispatcher
  (app/api/extensions/ext/[...path]/route.ts)
- Add manifest.json for each extension with metadata, env vars, and deps
- Add api-routes.ts pattern for extension-defined API endpoints
- Add code generation scripts (generate-extension-registry, create-extension)
- Add extensions.config.json for opt-in extension loading
- Add extensions.schema.json for config validation
- Add email service interface with noop default (lib/email/service.ts)
- Add CI workflow (core-build.yml) to verify core builds with zero extensions
- Add migration 045: expand account_type CHECK for untaxed_reserves
- Update CLAUDE.md with comprehensive extension system documentation
- Update all report engines and bookkeeping services for new imports
- Clean up extensions.schema.json to only list existing extensions

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

340 lines
10 KiB
TypeScript

import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems } from './lib/receipt-categorizer'
import { autoMatchReceipts } from './lib/receipt-matcher'
import { receiptOcrApiRoutes } from './api-routes'
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,
category: item.category,
bas_account: item.basAccount,
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',
sector: 'general',
apiRoutes: receiptOcrApiRoutes,
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)
},
}