# Receipt-OCR Extension — Implementation Summary This document describes the receipt-ocr extension: the first real extension built on the Part 3 event bus and extension registry infrastructure. It bridges the document archive to the receipt pipeline via events and establishes the canonical pattern for all future extensions. --- ## Problem Receipt-OCR functionality existed as built-in code (`lib/receipts/`, `app/api/receipts/`), but was disconnected from the event system: - Uploading a document via the archive did not trigger OCR - New bank transactions did not auto-match to receipts - No domain events were emitted when receipts were extracted, matched, or confirmed The event bus and extension registry (Part 3) were built but had zero real extensions using them. ## Solution A `receipt-ocr` extension that: 1. Listens to `document.uploaded` events and auto-triggers OCR on images 2. Listens to `transaction.synced` events and auto-matches receipts to new transactions 3. Emits its own domain events (`receipt.extracted`, `receipt.matched`, `receipt.confirmed`) so downstream extensions can react **Principle followed:** Services = reusable logic in `lib/`. Extensions = event-driven glue. API routes = HTTP interface. --- ## Files Changed ### New files | File | Purpose | |------|---------| | `extensions/receipt-ocr/index.ts` | The extension: settings, event handlers, extension object | | `app/api/extensions/receipt-ocr/settings/route.ts` | GET/PATCH API for per-user extension settings | ### Modified files | File | Change | |------|--------| | `lib/events/types.ts` | Added `Receipt` import and 3 new events to `CoreEvent` union | | `lib/extensions/loader.ts` | Imported and registered `receiptOcrExtension` | | `app/api/receipts/upload/route.ts` | Emits `receipt.extracted` after successful OCR | | `app/api/receipts/[id]/match/route.ts` | Emits `receipt.matched` after manual match; upgraded selects to fetch full objects | | `app/api/receipts/[id]/confirm/route.ts` | Emits `receipt.confirmed` with computed business/private totals | --- ## New Events Three events were added to `lib/events/types.ts`: ### `receipt.extracted` Fires when OCR extraction completes on a receipt image, whether via the direct upload path or the document archive path. ```typescript { type: 'receipt.extracted'; payload: { receipt: Receipt; documentId: string | null; // null when from direct upload path confidence: number; userId: string; }} ``` ### `receipt.matched` Fires when a receipt is linked to a bank transaction, whether by user manual action or extension auto-match. ```typescript { type: 'receipt.matched'; payload: { receipt: Receipt; transaction: Transaction; confidence: number; autoMatched: boolean; // true = extension, false = user manual userId: string; }} ``` ### `receipt.confirmed` Fires when a user confirms line item classifications (business vs private). ```typescript { type: 'receipt.confirmed'; payload: { receipt: Receipt; businessTotal: number; privateTotal: number; userId: string; }} ``` --- ## Event Retrofitting Existing API routes were retrofitted to emit events after their success paths. Each route received: - `import { eventBus } from '@/lib/events/bus'` - `import { ensureInitialized } from '@/lib/init'` - `ensureInitialized()` at module scope - `await eventBus.emit(...)` after the successful operation, before the response ### `app/api/receipts/upload/route.ts` Emits `receipt.extracted` after the complete receipt (with line items) is fetched, with `documentId: null` since this is the direct upload path. ### `app/api/receipts/[id]/match/route.ts` The PATCH handler's ownership verification queries were upgraded from `select('id')` to `select('*, line_items:receipt_line_items(*)')` and `select('*')` respectively, so the full receipt and transaction objects are available for the event payload. Emits `receipt.matched` with `autoMatched: false`. ### `app/api/receipts/[id]/confirm/route.ts` Computes `businessTotal` and `privateTotal` by iterating over the updated receipt's line items. Emits `receipt.confirmed` with these totals. --- ## Extension: `extensions/receipt-ocr/index.ts` ### Settings ```typescript interface ReceiptOcrSettings { autoOcrEnabled: boolean // default: true autoMatchEnabled: boolean // default: true autoMatchThreshold: number // default: 0.8 ocrConfidenceThreshold: number // default: 0.6 } ``` Stored as an `extension_data` row with `extension_id='receipt-ocr'`, `key='settings'`, `value=`. - `getSettings(userId)` reads from DB and merges with defaults (forward-compatible when new settings are added) - `saveSettings(userId, partial)` merges partial update with current settings, then upserts on the unique constraint `(user_id, extension_id, key)` ### Event Handler: `document.uploaded` When an image is uploaded via the document archive: 1. **Gate:** Is `document.mime_type` an image? (`image/jpeg|png|webp|gif`) — if not, return 2. **Gate:** Is `autoOcrEnabled` in user's settings? — if not, return 3. Downloads image from `documents` storage bucket 4. Converts to base64, calls `analyzeReceipt()` from `lib/receipts/receipt-analyzer.ts` 5. **Gate:** Is `extraction.confidence >= ocrConfidenceThreshold`? — if not, return 6. Calls `processLineItems()` from `lib/receipts/receipt-categorizer.ts` 7. Creates receipt record (status: `extracted`) + line items in DB 8. Emits `receipt.extracted` with `documentId: document.id` ### Event Handler: `transaction.synced` When new transactions arrive from banking sync: 1. **Gate:** Is `autoMatchEnabled`? — if not, return 2. Filters to expense transactions only (amount < 0) 3. Fetches unmatched receipts (`status IN ('extracted','confirmed')`, `matched_transaction_id IS NULL`) 4. Calls `autoMatchReceipts()` from `lib/receipts/receipt-matcher.ts` with `settings.autoMatchThreshold` 5. For each match: updates receipt + transaction bidirectional link, emits `receipt.matched` with `autoMatched: true` ### Extension Object ```typescript 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', ... }, { id: 'receipt-ocr-category', name: 'OCR Category Suggestion', ... }, ], settingsPanel: { label: 'Receipt OCR', path: '/settings/extensions/receipt-ocr' }, async onInstall(ctx) { await saveSettings(ctx.userId, DEFAULT_SETTINGS) }, } ``` --- ## Settings API: `app/api/extensions/receipt-ocr/settings/route.ts` Establishes the convention `app/api/extensions/{id}/settings/route.ts` for all extensions. - **GET** — Returns the current user's merged settings (DB value + defaults) - **PATCH** — Accepts a partial settings object, validates keys against an allowlist, saves via `saveSettings()` --- ## Existing Code Reused | Import | From | Used in | |--------|------|---------| | `analyzeReceipt()` | `lib/receipts/receipt-analyzer.ts` | `handleDocumentUploaded` | | `processLineItems()` | `lib/receipts/receipt-categorizer.ts` | `handleDocumentUploaded` | | `autoMatchReceipts()` | `lib/receipts/receipt-matcher.ts` | `handleTransactionSynced` | | `eventBus` | `lib/events/bus.ts` | Both handlers + retrofit | | `createClient()` | `lib/supabase/server.ts` | Settings + DB ops | No service logic was duplicated. The extension only acts as event-driven glue between existing services. --- ## Event Flow ``` Document Archive Upload Direct Receipt Upload Bank Sync | | | uploadDocument() POST /receipts/upload POST /banking/sync | | | emit document.uploaded analyzeReceipt() inline emit transaction.synced | | | v v v +-----------------+ emit receipt.extracted +---------------------+ | receipt-ocr | | receipt-ocr | | extension | | extension | | | | | | Gate: image? | | Gate: enabled? | | Gate: enabled? | | Fetch unmatched | | Download image | | autoMatchReceipts() | | analyzeReceipt()| | Link matches | | Create receipt | | emit receipt.matched| | emit receipt. | +---------------------+ | extracted | +-----------------+ User confirms receipt --> POST /receipts/[id]/confirm | emit receipt.confirmed | v [Future extensions] push-notifications ne-bilaga, etc. ``` --- ## Architectural Patterns Established 1. **Extensions never duplicate service logic.** They call existing functions from `lib/`. 2. **Extensions are gate-guarded.** Every handler checks user settings before doing work. 3. **Extensions emit domain events.** Downstream extensions react without coupling. 4. **Events fire from both paths.** Whether a receipt enters via archive (event-driven) or direct upload (API), the same `receipt.extracted` event fires. 5. **Settings use `extension_data` with `key='settings'`.** Helpers merge with defaults for forward-compatible schema evolution. 6. **`onInstall` seeds defaults.** Idempotent via upsert. 7. **Handlers never crash the emitter.** `Promise.allSettled` in the bus handles this. 8. **Console logging with `[extension-id]` prefix.** Convention for grep-ability. 9. **One-way dependency.** Base never imports from `extensions/`. Only `loader.ts` imports extension objects. --- ## Verification - `npx tsc --noEmit` passes with zero errors - Manual: upload image via document archive -> receipt auto-created with OCR extraction - Manual: sync bank transactions -> unmatched receipts auto-matched - Manual: direct receipt upload still works unchanged, now also emits `receipt.extracted` - Manual: confirm receipt -> emits `receipt.confirmed`