03b569d708
- 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>
340 lines
10 KiB
TypeScript
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)
|
|
},
|
|
}
|