Files
accounted/dev_docs/base_architecture/RECEIPT_OCR_EXTENSION.md
T
2026-02-19 09:48:02 +01:00

10 KiB

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.

{ 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.

{ 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).

{ 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

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=<jsonb>.

  • 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

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