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>
This commit is contained in:
Jakob Wennberg
2026-02-23 09:24:21 +01:00
co-authored by Claude Opus 4.6
parent 59d935f2cc
commit ef5a84a5d5
24 changed files with 1058 additions and 185 deletions
+21 -13
View File
@@ -1,5 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { Extension } from '@/lib/extensions/types'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { Transaction, EntityType } from '@/types'
import {
@@ -26,7 +25,15 @@ const DEFAULT_SETTINGS: AiCategorizationSettings = {
providerModel: 'claude-haiku-4-5-20251001',
}
/** Get settings via ExtensionContext (preferred in event handlers) */
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<AiCategorizationSettings> {
const stored = await ctx.settings.get<Partial<AiCategorizationSettings>>()
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
}
/** Get settings for external callers (settings routes, on-demand API) */
export async function getSettings(userId: string): Promise<AiCategorizationSettings> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
@@ -38,7 +45,6 @@ export async function getSettings(userId: string): Promise<AiCategorizationSetti
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<AiCategorizationSettings>) }
}
@@ -49,6 +55,7 @@ export async function saveSettings(
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
@@ -87,6 +94,7 @@ export async function categorizeTransactions(
userId: string,
transactionIds: string[]
): Promise<CategorizationSuggestion[]> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const settings = await getSettings(userId)
@@ -125,12 +133,14 @@ export async function categorizeTransactions(
// ============================================================
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>
payload: EventPayload<'transaction.synced'>,
ctx?: ExtensionContext
): Promise<void> {
const { transactions: syncedTransactions, userId } = payload
const log = ctx?.log ?? console
// Gate: Is autoSuggestEnabled?
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoSuggestEnabled) {
return
}
@@ -143,12 +153,10 @@ async function handleTransactionSynced(
return
}
console.log(
`[ai-categorization] Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`
)
log.info(`Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`)
try {
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({
id: t.id,
@@ -173,11 +181,11 @@ async function handleTransactionSynced(
await storeSuggestions(userId, qualifiedSuggestions, supabase)
}
console.log(
`[ai-categorization] Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
log.info(
`Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
)
} catch (error) {
console.error('[ai-categorization] handleTransactionSynced failed:', error)
log.error('handleTransactionSynced failed:', error)
}
}
@@ -251,6 +259,6 @@ export const aiCategorizationExtension: Extension = {
path: '/settings/extensions/ai-categorization',
},
async onInstall(ctx) {
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
+20 -19
View File
@@ -1,16 +1,9 @@
import type { Extension } from '@/lib/extensions/types'
import { createClient, createServiceClient } from '@/lib/supabase/server'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import {
startAuthorization,
getASPSPs,
createSession,
getAccountBalance,
isConsentExpiringSoon,
getDaysUntilExpiry,
type ASPSP,
type AccountInfo,
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
import type { StoredAccount } from './types'
@@ -41,7 +34,8 @@ export const enableBankingExtension: Extension = {
{
method: 'GET',
path: '/banks',
handler: async () => {
handler: async (_request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
try {
const aspsps = await getASPSPs('SE')
const banks = aspsps.map((aspsp: ASPSP) => ({
@@ -52,7 +46,7 @@ export const enableBankingExtension: Extension = {
}))
return NextResponse.json({ banks })
} catch (error) {
console.error('Error fetching banks:', error)
log.error('Error fetching banks:', error)
return NextResponse.json({
banks: [
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
@@ -67,8 +61,9 @@ export const enableBankingExtension: Extension = {
{
method: 'POST',
path: '/connect',
handler: async (request: Request) => {
const supabase = await createClient()
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
@@ -108,7 +103,7 @@ export const enableBankingExtension: Extension = {
.single()
if (error) {
console.error('Database error:', error)
log.error('Database error:', error)
throw new Error('Failed to store connection')
}
@@ -117,7 +112,7 @@ export const enableBankingExtension: Extension = {
authorization_url: url,
})
} catch (error) {
console.error('Bank connection error:', error)
log.error('Bank connection error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
@@ -128,8 +123,9 @@ export const enableBankingExtension: Extension = {
{
method: 'POST',
path: '/sync',
handler: async (request: Request) => {
const supabase = await createClient()
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
@@ -161,6 +157,9 @@ export const enableBankingExtension: Extension = {
.toISOString()
.split('T')[0]
// Use ctx.services.ingestTransactions when available
const ingestFn = ctx?.services.ingestTransactions
let totalImported = 0
let totalDuplicates = 0
@@ -171,7 +170,8 @@ export const enableBankingExtension: Extension = {
connection.id,
account,
fromDate,
toDate
toDate,
ingestFn
)
totalImported += result.imported
@@ -198,7 +198,8 @@ export const enableBankingExtension: Extension = {
.limit(totalImported)
if (syncedTransactions && syncedTransactions.length > 0) {
await eventBus.emit({
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'transaction.synced',
payload: { transactions: syncedTransactions as Transaction[], userId: user.id },
})
@@ -211,7 +212,7 @@ export const enableBankingExtension: Extension = {
last_synced_at: syncedAt,
})
} catch (error) {
console.error('Sync error:', error)
log.error('Sync error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Sync failed' },
{ status: 500 }
+17 -4
View File
@@ -1,8 +1,16 @@
import { SupabaseClient } from '@supabase/supabase-js'
import type { SupabaseClient } from '@supabase/supabase-js'
import { getTransactions, getAccountBalance } from './api-client'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest'
import type { RawTransaction, IngestResult } from '@/types'
import type { StoredAccount } from '../types'
/** Ingest function signature — matches lib/transactions/ingest */
export type IngestFn = (
supabase: SupabaseClient,
userId: string,
raw: RawTransaction[]
) => Promise<IngestResult>
export interface SyncResult {
imported: number
duplicates: number
@@ -14,6 +22,10 @@ export interface SyncResult {
*
* Fetches transactions from the Enable Banking API, converts to RawTransaction
* format, and delegates to the shared ingestion pipeline.
*
* @param ingest - Optional ingest function override (defaults to core ingestTransactions).
* When called from an extension handler with ctx.services.ingestTransactions,
* pass that function to avoid direct @/lib imports.
*/
export async function syncAccountTransactions(
supabase: SupabaseClient,
@@ -21,7 +33,8 @@ export async function syncAccountTransactions(
connectionId: string,
account: StoredAccount,
fromDate: string,
toDate: string
toDate: string,
ingest: IngestFn = defaultIngest
): Promise<SyncResult> {
const bankTransactions = await getTransactions(
account.uid,
@@ -44,7 +57,7 @@ export async function syncAccountTransactions(
import_source: 'enable_banking',
}))
const ingestResult = await ingestTransactions(supabase, userId, rawTransactions)
const ingestResult = await ingest(supabase, userId, rawTransactions)
// Update account balance
try {
+22 -12
View File
@@ -1,8 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { analyzeInvoice } from './lib/invoice-analyzer'
import { matchSupplier } from './lib/supplier-matcher'
import type { Extension } from '@/lib/extensions/types'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { InvoiceInboxSettings } from './types'
@@ -17,7 +15,15 @@ const DEFAULT_SETTINGS: InvoiceInboxSettings = {
inboxEmail: null,
}
/** Get settings via ExtensionContext (preferred in event handlers) */
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<InvoiceInboxSettings> {
const stored = await ctx.settings.get<Partial<InvoiceInboxSettings>>()
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
}
/** Get settings for external callers (settings routes, API routes) */
export async function getSettings(userId: string): Promise<InvoiceInboxSettings> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
@@ -40,6 +46,7 @@ export async function saveSettings(
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
@@ -73,9 +80,11 @@ const INVOICE_MIME_TYPES = [
* be auto-processed as a supplier invoice.
*/
async function handleDocumentUploaded(
payload: EventPayload<'document.uploaded'>
payload: EventPayload<'document.uploaded'>,
ctx?: ExtensionContext
): Promise<void> {
const { document, userId } = payload
const log = ctx?.log ?? console
// Gate: Is it a supported file type?
if (!document.mime_type || !INVOICE_MIME_TYPES.includes(document.mime_type)) {
@@ -83,13 +92,13 @@ async function handleDocumentUploaded(
}
// Gate: Is autoProcessEnabled?
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoProcessEnabled) {
return
}
// Gate: Was this document already processed as an inbox item?
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: existing } = await supabase
.from('invoice_inbox_items')
.select('id')
@@ -101,7 +110,7 @@ async function handleDocumentUploaded(
return
}
console.log(`[invoice-inbox] Auto-process triggered for document ${document.id}`)
log.info(`Auto-process triggered for document ${document.id}`)
try {
// Create inbox item
@@ -117,7 +126,7 @@ async function handleDocumentUploaded(
.single()
if (insertError || !inboxItem) {
console.error('[invoice-inbox] Failed to create inbox item:', insertError)
log.error('Failed to create inbox item:', insertError)
return
}
@@ -176,7 +185,8 @@ async function handleDocumentUploaded(
.single()
if (updatedItem) {
await eventBus.emit({
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'supplier_invoice.extracted',
payload: {
inboxItem: updatedItem,
@@ -186,9 +196,9 @@ async function handleDocumentUploaded(
})
}
console.log(`[invoice-inbox] Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`)
log.info(`Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`)
} catch (error) {
console.error('[invoice-inbox] handleDocumentUploaded failed:', error)
log.error('handleDocumentUploaded failed:', error)
}
}
@@ -208,6 +218,6 @@ export const invoiceInboxExtension: Extension = {
path: '/settings/extensions/invoice-inbox',
},
async onInstall(ctx) {
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
+36 -24
View File
@@ -9,8 +9,7 @@
* check extension setting -> build payload -> send via unified pipeline
*/
import { createClient } from '@/lib/supabase/server'
import type { Extension } from '@/lib/extensions/types'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import { sendNotificationToUser } from './notification-sender'
import {
@@ -41,7 +40,15 @@ const DEFAULT_SETTINGS: PushNotificationSettings = {
receiptMatchedEnabled: true,
}
/** Get settings via ExtensionContext (preferred in event handlers) */
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<PushNotificationSettings> {
const stored = await ctx.settings.get<Partial<PushNotificationSettings>>()
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
}
/** Get settings for external callers (settings routes, cron jobs) */
export async function getSettings(userId: string): Promise<PushNotificationSettings> {
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { data } = await supabase
@@ -53,7 +60,6 @@ export async function getSettings(userId: string): Promise<PushNotificationSetti
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<PushNotificationSettings>) }
}
@@ -64,6 +70,7 @@ export async function saveSettings(
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
@@ -86,14 +93,15 @@ export async function saveSettings(
// ============================================================
async function handlePeriodLocked(
payload: EventPayload<'period.locked'>
payload: EventPayload<'period.locked'>,
ctx?: ExtensionContext
): Promise<void> {
const { period, userId } = payload
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.periodLockedEnabled) return
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createPeriodLockedPayload(period.name, period.id)
const result = await sendNotificationToUser(
@@ -105,19 +113,20 @@ async function handlePeriodLocked(
)
if (result.sent) {
console.log(`[push-notifications] Period locked notification sent for ${period.name}`)
(ctx?.log ?? console).info(`Period locked notification sent for ${period.name}`)
}
}
async function handleYearClosed(
payload: EventPayload<'period.year_closed'>
payload: EventPayload<'period.year_closed'>,
ctx?: ExtensionContext
): Promise<void> {
const { period, userId } = payload
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.periodYearClosedEnabled) return
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createYearClosedPayload(period.name, period.id)
const result = await sendNotificationToUser(
@@ -129,19 +138,20 @@ async function handleYearClosed(
)
if (result.sent) {
console.log(`[push-notifications] Year closed notification sent for ${period.name}`)
(ctx?.log ?? console).info(`Year closed notification sent for ${period.name}`)
}
}
async function handleInvoiceSent(
payload: EventPayload<'invoice.sent'>
payload: EventPayload<'invoice.sent'>,
ctx?: ExtensionContext
): Promise<void> {
const { invoice, userId } = payload
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.invoiceSentEnabled) return
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createInvoiceSentPayload(
invoice.invoice_number,
invoice.id
@@ -156,19 +166,20 @@ async function handleInvoiceSent(
)
if (result.sent) {
console.log(`[push-notifications] Invoice sent notification for #${invoice.invoice_number}`)
(ctx?.log ?? console).info(`Invoice sent notification for #${invoice.invoice_number}`)
}
}
async function handleReceiptExtracted(
payload: EventPayload<'receipt.extracted'>
payload: EventPayload<'receipt.extracted'>,
ctx?: ExtensionContext
): Promise<void> {
const { receipt, userId } = payload
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.receiptExtractedEnabled) return
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createReceiptExtractedPayload(
receipt.merchant_name,
receipt.id
@@ -183,19 +194,20 @@ async function handleReceiptExtracted(
)
if (result.sent) {
console.log(`[push-notifications] Receipt extracted notification for ${receipt.id}`)
(ctx?.log ?? console).info(`Receipt extracted notification for ${receipt.id}`)
}
}
async function handleReceiptMatched(
payload: EventPayload<'receipt.matched'>
payload: EventPayload<'receipt.matched'>,
ctx?: ExtensionContext
): Promise<void> {
const { receipt, transaction, userId } = payload
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.receiptMatchedEnabled) return
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const notificationPayload = createReceiptMatchedPayload(receipt.id, transaction.id)
const result = await sendNotificationToUser(
@@ -207,7 +219,7 @@ async function handleReceiptMatched(
)
if (result.sent) {
console.log(`[push-notifications] Receipt matched notification for ${receipt.id}`)
(ctx?.log ?? console).info(`Receipt matched notification for ${receipt.id}`)
}
}
@@ -231,6 +243,6 @@ export const pushNotificationsExtension: Extension = {
path: '/settings/extensions/push-notifications',
},
async onInstall(ctx) {
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
+38 -26
View File
@@ -1,11 +1,9 @@
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems } from './lib/receipt-categorizer'
import { autoMatchReceipts } from './lib/receipt-matcher'
import type { Extension } from '@/lib/extensions/types'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { Receipt, Transaction } from '@/types'
import type { Receipt } from '@/types'
// ============================================================
// Settings
@@ -25,7 +23,15 @@ const DEFAULT_SETTINGS: ReceiptOcrSettings = {
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
@@ -49,6 +55,7 @@ export async function saveSettings(
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
await supabase
@@ -76,9 +83,11 @@ 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'>
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)) {
@@ -86,15 +95,15 @@ async function handleDocumentUploaded(
}
// Gate: Is autoOcrEnabled?
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoOcrEnabled) {
return
}
console.log(`[receipt-ocr] Auto-OCR triggered for document ${document.id}`)
log.info(`Auto-OCR triggered for document ${document.id}`)
try {
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Download image from storage
const { data: fileData, error: downloadError } = await supabase.storage
@@ -102,7 +111,7 @@ async function handleDocumentUploaded(
.download(document.storage_path)
if (downloadError || !fileData) {
console.error('[receipt-ocr] Failed to download document:', downloadError)
log.error('Failed to download document:', downloadError)
return
}
@@ -116,8 +125,8 @@ async function handleDocumentUploaded(
// Gate: Is confidence high enough?
if (extraction.confidence < settings.ocrConfidenceThreshold) {
console.log(
`[receipt-ocr] Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping`
log.info(
`Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping`
)
return
}
@@ -155,7 +164,7 @@ async function handleDocumentUploaded(
.single()
if (insertError || !receipt) {
console.error('[receipt-ocr] Failed to create receipt:', insertError)
log.error('Failed to create receipt:', insertError)
return
}
@@ -188,7 +197,8 @@ async function handleDocumentUploaded(
.single()
// Emit receipt.extracted
await eventBus.emit({
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,
@@ -198,9 +208,9 @@ async function handleDocumentUploaded(
},
})
console.log(`[receipt-ocr] Receipt ${receipt.id} created from document ${document.id}`)
log.info(`Receipt ${receipt.id} created from document ${document.id}`)
} catch (error) {
console.error('[receipt-ocr] handleDocumentUploaded failed:', error)
log.error('handleDocumentUploaded failed:', error)
}
}
@@ -208,12 +218,14 @@ async function handleDocumentUploaded(
* When new transactions arrive from banking sync, auto-match unmatched receipts.
*/
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>
payload: EventPayload<'transaction.synced'>,
ctx?: ExtensionContext
): Promise<void> {
const { transactions: syncedTransactions, userId } = payload
const log = ctx?.log ?? console
// Gate: Is autoMatchEnabled?
const settings = await getSettings(userId)
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoMatchEnabled) {
return
}
@@ -224,12 +236,10 @@ async function handleTransactionSynced(
return
}
console.log(
`[receipt-ocr] Auto-match triggered for ${expenseTransactions.length} expense transactions`
)
log.info(`Auto-match triggered for ${expenseTransactions.length} expense transactions`)
try {
const supabase = await createClient()
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Fetch unmatched receipts
const { data: unmatchedReceipts, error: fetchError } = await supabase
@@ -250,6 +260,8 @@ async function handleTransactionSynced(
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
@@ -268,7 +280,7 @@ async function handleTransactionSynced(
.eq('id', match.transaction.id)
// Emit receipt.matched
await eventBus.emit({
await emit({
type: 'receipt.matched',
payload: {
receipt,
@@ -279,12 +291,12 @@ async function handleTransactionSynced(
},
})
console.log(
`[receipt-ocr] Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})`
log.info(
`Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})`
)
}
} catch (error) {
console.error('[receipt-ocr] handleTransactionSynced failed:', error)
log.error('handleTransactionSynced failed:', error)
}
}
@@ -317,6 +329,6 @@ export const receiptOcrExtension: Extension = {
path: '/settings/extensions/receipt-ocr',
},
async onInstall(ctx) {
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}