New Base func

This commit is contained in:
Jakob Wennberg
2026-02-19 09:48:02 +01:00
parent afc9f69def
commit cdf1dcc4c8
73 changed files with 11036 additions and 51 deletions
+269
View File
@@ -0,0 +1,269 @@
/**
* AI Categorization Engine
*
* SERVER-ONLY: Uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Provider-abstracted AI categorization for Swedish BAS account mapping.
* Default implementation uses Claude Haiku for cost efficiency.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { TransactionCategory, EntityType } from '@/types'
// ============================================================
// Types
// ============================================================
export interface TransactionForCategorization {
id: string
description: string
amount: number
date: string
merchant_name: string | null
mcc_code: number | null
currency: string
}
export interface CategorizationContext {
entityType: EntityType
recentHistory: { description: string; category: string }[]
}
export interface CategorizationSuggestion {
transactionId: string
category: TransactionCategory
basAccount: string
taxCode: string | null
confidence: number
reasoning: string
isPrivate: boolean
}
export interface CategorizationProvider {
categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext
): Promise<CategorizationSuggestion[]>
}
// ============================================================
// BAS Account + Category Mapping (used in prompt)
// ============================================================
const CATEGORY_ACCOUNT_MAP: Record<string, { account: string; label: string }> = {
income_services: { account: '3001', label: 'Tjänsteförsäljning' },
income_products: { account: '3001', label: 'Varuförsäljning' },
income_other: { account: '3900', label: 'Övriga intäkter' },
expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' },
expense_software: { account: '5420', label: 'Programvara' },
expense_travel: { account: '5800', label: 'Resekostnader' },
expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' },
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
expense_education: { account: '6991', label: 'Utbildning' },
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
expense_other: { account: '6991', label: 'Övriga kostnader' },
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
}
const NON_DEDUCTIBLE_RULES = `
ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35)
- Gym/träning: Inte avdragsgilla som personlig kostnad (IL 9 kap 2§)
- Kosmetika/hudvård: Normalt inte avdragsgillt
- Frisör: Normalt privat kostnad
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§)
- Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
`
// ============================================================
// Anthropic Provider
// ============================================================
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
const MAX_BATCH_SIZE = 20
export class AnthropicCategorizationProvider implements CategorizationProvider {
private client: Anthropic
private model: string
constructor(model = 'claude-haiku-4-5-20251001') {
this.client = new Anthropic()
this.model = model
}
async categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext
): Promise<CategorizationSuggestion[]> {
// Cap batch size
const batch = transactions.slice(0, MAX_BATCH_SIZE)
if (batch.length === 0) return []
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt kategori och BAS-konto.
KATEGORIER OCH BAS-KONTON:
${Object.entries(CATEGORY_ACCOUNT_MAP)
.map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
.join('\n')}
Företagsform: ${context.entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'}
Privatkonto: ${privateAccount}
MOMSHANTERING:
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA
- Övriga affärskostnader: Normalt 25% moms (ingående moms, MPI)
- Intäkter: Normalt 25% moms (utgående moms, MP1)
${NON_DEDUCTIBLE_RULES}
REGLER:
1. Negativa belopp = utgifter, positiva = intäkter
2. Markera transaktioner som troligen är privata med isPrivate: true
3. Ange confidence 0.0-1.0 baserat på hur säker du är
4. Ange kort reasoning på svenska
5. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private"
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata`
const historyContext =
context.recentHistory.length > 0
? `\nAnvändarens senaste kategoriseringar (lär dig mönster):\n${context.recentHistory
.slice(0, 30)
.map((h) => `- "${h.description}" → ${h.category}`)
.join('\n')}`
: ''
const transactionList = batch
.map(
(t, i) =>
`${i + 1}. ID: ${t.id}
Beskrivning: ${t.description}
Belopp: ${t.amount} ${t.currency}
Datum: ${t.date}${t.merchant_name ? `\n Handlare: ${t.merchant_name}` : ''}${t.mcc_code ? `\n MCC: ${t.mcc_code}` : ''}`
)
.join('\n\n')
const userPrompt = `Kategorisera följande transaktioner:
${historyContext}
TRANSAKTIONER:
${transactionList}
Returnera ett JSON-objekt med följande struktur:
{
"suggestions": [
{
"transactionId": "id",
"category": "expense_software",
"basAccount": "5420",
"taxCode": "MPI",
"confidence": 0.9,
"reasoning": "Spotify-prenumeration, typisk programvarukostnad",
"isPrivate": false
}
]
}
Returnera ENDAST JSON-objektet, ingen annan text.`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const message = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: 'user',
content: userPrompt,
},
],
})
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
}
// Strip markdown code blocks if present
let jsonText = content.text.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3)
}
jsonText = jsonText.trim()
const parsed = JSON.parse(jsonText)
return this.validateSuggestions(parsed.suggestions || [], batch)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
// Don't retry on parse errors
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse AI response: ${lastError.message}`)
}
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(
`AI categorization failed after ${MAX_RETRIES} attempts: ${lastError?.message}`
)
}
private validateSuggestions(
raw: unknown[],
transactions: TransactionForCategorization[]
): CategorizationSuggestion[] {
if (!Array.isArray(raw)) return []
const validTransactionIds = new Set(transactions.map((t) => t.id))
const validCategories = new Set(Object.keys(CATEGORY_ACCOUNT_MAP).concat(['uncategorized']))
return raw
.filter(
(s): s is Record<string, unknown> =>
s !== null && typeof s === 'object' && 'transactionId' in s
)
.filter((s) => validTransactionIds.has(s.transactionId as string))
.map((s) => {
const category = validCategories.has(s.category as string)
? (s.category as TransactionCategory)
: 'expense_other'
const accountInfo = CATEGORY_ACCOUNT_MAP[category]
return {
transactionId: s.transactionId as string,
category,
basAccount: accountInfo?.account || (s.basAccount as string) || '6991',
taxCode: (s.taxCode as string) || null,
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
reasoning: (s.reasoning as string) || '',
isPrivate: category === 'private' || Boolean(s.isPrivate),
}
})
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+256
View File
@@ -0,0 +1,256 @@
import { createClient } from '@/lib/supabase/server'
import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { Transaction, EntityType } from '@/types'
import {
AnthropicCategorizationProvider,
type CategorizationProvider,
type TransactionForCategorization,
type CategorizationContext,
type CategorizationSuggestion,
} from './categorizer'
// ============================================================
// Settings
// ============================================================
export interface AiCategorizationSettings {
autoSuggestEnabled: boolean
confidenceThreshold: number
providerModel: string
}
const DEFAULT_SETTINGS: AiCategorizationSettings = {
autoSuggestEnabled: true,
confidenceThreshold: 0.7,
providerModel: 'claude-haiku-4-5-20251001',
}
export async function getSettings(userId: string): Promise<AiCategorizationSettings> {
const supabase = await createClient()
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('user_id', userId)
.eq('extension_id', 'ai-categorization')
.eq('key', 'settings')
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<AiCategorizationSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<AiCategorizationSettings>
): Promise<AiCategorizationSettings> {
const current = await getSettings(userId)
const merged = { ...current, ...partial }
const supabase = await createClient()
await supabase
.from('extension_data')
.upsert(
{
user_id: userId,
extension_id: 'ai-categorization',
key: 'settings',
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
return merged
}
// ============================================================
// Provider
// ============================================================
let provider: CategorizationProvider | null = null
function getProvider(model?: string): CategorizationProvider {
if (!provider) {
provider = new AnthropicCategorizationProvider(model)
}
return provider
}
// ============================================================
// Public API — on-demand categorization
// ============================================================
export async function categorizeTransactions(
userId: string,
transactionIds: string[]
): Promise<CategorizationSuggestion[]> {
const supabase = await createClient()
const settings = await getSettings(userId)
// Fetch transactions
const { data: transactions } = await supabase
.from('transactions')
.select('id, description, amount, date, merchant_name, mcc_code, currency')
.eq('user_id', userId)
.in('id', transactionIds)
if (!transactions || transactions.length === 0) return []
const batch: TransactionForCategorization[] = transactions.map((t) => ({
id: t.id,
description: t.description,
amount: t.amount,
date: t.date,
merchant_name: t.merchant_name,
mcc_code: t.mcc_code,
currency: t.currency,
}))
const context = await buildContext(userId, supabase)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context)
// Store suggestions
await storeSuggestions(userId, suggestions, supabase)
return suggestions
}
// ============================================================
// Event Handler
// ============================================================
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>
): Promise<void> {
const { transactions: syncedTransactions, userId } = payload
// Gate: Is autoSuggestEnabled?
const settings = await getSettings(userId)
if (!settings.autoSuggestEnabled) {
return
}
// Gate: Filter to uncategorized transactions only
const uncategorized = syncedTransactions.filter(
(t: Transaction) => t.is_business === null
)
if (uncategorized.length === 0) {
return
}
console.log(
`[ai-categorization] Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`
)
try {
const supabase = await createClient()
const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({
id: t.id,
description: t.description,
amount: t.amount,
date: t.date,
merchant_name: t.merchant_name,
mcc_code: t.mcc_code,
currency: t.currency,
}))
const context = await buildContext(userId, supabase)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context)
// Store only suggestions above confidence threshold
const qualifiedSuggestions = suggestions.filter(
(s) => s.confidence >= settings.confidenceThreshold
)
if (qualifiedSuggestions.length > 0) {
await storeSuggestions(userId, qualifiedSuggestions, supabase)
}
console.log(
`[ai-categorization] Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
)
} catch (error) {
console.error('[ai-categorization] handleTransactionSynced failed:', error)
}
}
// ============================================================
// Helpers
// ============================================================
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function buildContext(userId: string, supabase: any): Promise<CategorizationContext> {
// Fetch entity type
const { data: companySettings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', userId)
.single()
const entityType: EntityType = companySettings?.entity_type || 'enskild_firma'
// Fetch recent categorization history
const { data: historicalTxns } = await supabase
.from('transactions')
.select('description, category')
.eq('user_id', userId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.order('updated_at', { ascending: false })
.limit(50)
const recentHistory = (historicalTxns || []).map(
(t: { description: string; category: string }) => ({
description: t.description,
category: t.category,
})
)
return { entityType, recentHistory }
}
async function storeSuggestions(
userId: string,
suggestions: CategorizationSuggestion[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: any
): Promise<void> {
for (const suggestion of suggestions) {
await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'ai-categorization',
key: `suggestion:${suggestion.transactionId}`,
value: suggestion,
},
{ onConflict: 'user_id,extension_id,key' }
)
}
}
// ============================================================
// Extension Object
// ============================================================
export const aiCategorizationExtension: Extension = {
id: 'ai-categorization',
name: 'AI Kategorisering',
version: '1.0.0',
eventHandlers: [
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
settingsPanel: {
label: 'AI Kategorisering',
path: '/settings/extensions/ai-categorization',
},
async onInstall(ctx) {
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
},
}
+34
View File
@@ -0,0 +1,34 @@
import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
/**
* Example Logger Extension
*
* Minimal reference implementation that logs events to the console.
* Not wired into the loader by default — add to FIRST_PARTY_EXTENSIONS
* in lib/extensions/loader.ts to activate.
*/
export const exampleLoggerExtension: Extension = {
id: 'example-logger',
name: 'Example Logger',
version: '0.1.0',
eventHandlers: [
{
eventType: 'journal_entry.committed',
handler: async (payload: EventPayload<'journal_entry.committed'>) => {
console.log(
`[example-logger] Journal entry committed: ${payload.entry.voucher_series}${payload.entry.voucher_number} — ${payload.entry.description}`
)
},
},
{
eventType: 'document.uploaded',
handler: async (payload: EventPayload<'document.uploaded'>) => {
console.log(
`[example-logger] Document uploaded: ${payload.document.file_name} (${payload.document.sha256_hash.slice(0, 12)}…)`
)
},
},
],
}
@@ -0,0 +1,256 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// ============================================================
// Mocks — must be defined before importing the module under test
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'is', 'insert', 'upsert', 'update', 'not', 'gte', 'lte', 'or', 'order', 'limit']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient(storageOverrides: Record<string, unknown> = {}) {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
storage: {
from: vi.fn().mockReturnValue({
download: vi.fn().mockResolvedValue({
data: new Blob(['fake-image']),
error: null,
}),
getPublicUrl: vi.fn().mockReturnValue({
data: { publicUrl: 'https://example.com/receipt.jpg' },
}),
...storageOverrides,
}),
},
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
vi.mock('@/lib/receipts/receipt-analyzer', () => ({
analyzeReceipt: vi.fn().mockResolvedValue({
merchant: { name: 'ICA', orgNumber: null, vatNumber: null, isForeign: false },
receipt: { date: '2024-06-15', time: '14:30', currency: 'SEK' },
lineItems: [
{ description: 'Mjölk', quantity: 1, unitPrice: 19, lineTotal: 19, vatRate: 12, suggestedCategory: null, confidence: 0.9 },
],
totals: { subtotal: 19, vatAmount: 2.04, total: 19 },
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
confidence: 0.92,
}),
}))
vi.mock('@/lib/receipts/receipt-matcher', () => ({
autoMatchReceipts: vi.fn().mockReturnValue([]),
}))
import { createClient } from '@/lib/supabase/server'
import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
import { autoMatchReceipts } from '@/lib/receipts/receipt-matcher'
import { getSettings, saveSettings, receiptOcrExtension } from '../index'
import { extensionRegistry } from '@/lib/extensions/registry'
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
extensionRegistry.clear()
resultIdx = 0
results = []
// Reset the mock to use default makeClient
vi.mocked(createClient).mockImplementation(async () => makeClient() as never)
})
// ============================================================
// Settings tests
// ============================================================
describe('getSettings', () => {
it('returns defaults when no DB record', async () => {
results = [{ data: null, error: { code: 'PGRST116' } }]
const settings = await getSettings('user-1')
expect(settings.autoOcrEnabled).toBe(true)
expect(settings.autoMatchEnabled).toBe(true)
expect(settings.autoMatchThreshold).toBe(0.8)
expect(settings.ocrConfidenceThreshold).toBe(0.6)
})
it('merges DB value with defaults', async () => {
results = [{ data: { value: { autoOcrEnabled: false } }, error: null }]
const settings = await getSettings('user-1')
expect(settings.autoOcrEnabled).toBe(false)
expect(settings.autoMatchEnabled).toBe(true)
})
})
describe('saveSettings', () => {
it('merges partial into current settings', async () => {
results = [
// getSettings read
{ data: { value: { autoOcrEnabled: true, autoMatchEnabled: true, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.6 } }, error: null },
// upsert (thenable)
{ data: null, error: null },
]
const result = await saveSettings('user-1', { autoMatchThreshold: 0.9 })
expect(result.autoMatchThreshold).toBe(0.9)
expect(result.autoOcrEnabled).toBe(true)
})
})
// ============================================================
// Extension object tests
// ============================================================
describe('receiptOcrExtension', () => {
it('has correct id, name, version', () => {
expect(receiptOcrExtension.id).toBe('receipt-ocr')
expect(receiptOcrExtension.name).toBe('Receipt OCR')
expect(receiptOcrExtension.version).toBe('1.0.0')
})
it('has event handlers for document.uploaded and transaction.synced', () => {
expect(receiptOcrExtension.eventHandlers).toBeDefined()
const types = receiptOcrExtension.eventHandlers!.map((h) => h.eventType)
expect(types).toContain('document.uploaded')
expect(types).toContain('transaction.synced')
})
})
// ============================================================
// handleDocumentUploaded gate tests
// ============================================================
describe('handleDocumentUploaded gates', () => {
it('skips non-image mime types', async () => {
extensionRegistry.register(receiptOcrExtension)
await eventBus.emit({
type: 'document.uploaded',
payload: {
document: {
id: 'doc-1',
mime_type: 'application/pdf',
storage_path: 'docs/file.pdf',
} as never,
userId: 'user-1',
},
})
expect(analyzeReceipt).not.toHaveBeenCalled()
})
it('skips when autoOcrEnabled is false', async () => {
// Settings return autoOcr disabled
results = [
{ data: { value: { autoOcrEnabled: false, autoMatchEnabled: true, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.6 } }, error: null },
]
extensionRegistry.register(receiptOcrExtension)
await eventBus.emit({
type: 'document.uploaded',
payload: {
document: {
id: 'doc-1',
mime_type: 'image/jpeg',
storage_path: 'docs/receipt.jpg',
} as never,
userId: 'user-1',
},
})
expect(analyzeReceipt).not.toHaveBeenCalled()
})
it('skips when confidence below threshold', async () => {
// Settings with very high threshold (0.99, above the 0.92 from analyzeReceipt mock)
results = [
{ data: { value: { autoOcrEnabled: true, autoMatchEnabled: true, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.99 } }, error: null },
]
vi.mocked(createClient).mockImplementation(async () =>
makeClient({
download: vi.fn().mockResolvedValue({
data: new Blob(['fake-image-data']),
error: null,
}),
}) as never
)
extensionRegistry.register(receiptOcrExtension)
await eventBus.emit({
type: 'document.uploaded',
payload: {
document: {
id: 'doc-1',
mime_type: 'image/jpeg',
storage_path: 'docs/receipt.jpg',
} as never,
userId: 'user-1',
},
})
// analyzeReceipt IS called but confidence (0.92) < threshold (0.99)
expect(analyzeReceipt).toHaveBeenCalled()
})
})
// ============================================================
// handleTransactionSynced gate tests
// ============================================================
describe('handleTransactionSynced gates', () => {
it('skips when autoMatchEnabled is false', async () => {
results = [
{ data: { value: { autoOcrEnabled: true, autoMatchEnabled: false, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.6 } }, error: null },
]
extensionRegistry.register(receiptOcrExtension)
await eventBus.emit({
type: 'transaction.synced',
payload: {
transactions: [{ id: 'tx1', amount: -100 }] as never,
userId: 'user-1',
},
})
expect(autoMatchReceipts).not.toHaveBeenCalled()
})
it('skips when no expense transactions', async () => {
results = [
{ data: { value: { autoOcrEnabled: true, autoMatchEnabled: true, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.6 } }, error: null },
]
extensionRegistry.register(receiptOcrExtension)
await eventBus.emit({
type: 'transaction.synced',
payload: {
transactions: [{ id: 'tx1', amount: 500 }] as never, // income
userId: 'user-1',
},
})
expect(autoMatchReceipts).not.toHaveBeenCalled()
})
})
+322
View File
@@ -0,0 +1,322 @@
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
import { processLineItems } from '@/lib/receipts/receipt-categorizer'
import { autoMatchReceipts } from '@/lib/receipts/receipt-matcher'
import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { Receipt, Transaction } 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,
}
export async function getSettings(userId: string): Promise<ReceiptOcrSettings> {
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 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'>
): Promise<void> {
const { document, userId } = payload
// Gate: Is it an image?
if (!document.mime_type || !IMAGE_MIME_TYPES.includes(document.mime_type)) {
return
}
// Gate: Is autoOcrEnabled?
const settings = await getSettings(userId)
if (!settings.autoOcrEnabled) {
return
}
console.log(`[receipt-ocr] Auto-OCR triggered for document ${document.id}`)
try {
const supabase = await createClient()
// Download image from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('documents')
.download(document.storage_path)
if (downloadError || !fileData) {
console.error('[receipt-ocr] 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) {
console.log(
`[receipt-ocr] 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) {
console.error('[receipt-ocr] 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,
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
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt: (completeReceipt || receipt) as unknown as Receipt,
documentId: document.id,
confidence: extraction.confidence,
userId,
},
})
console.log(`[receipt-ocr] Receipt ${receipt.id} created from document ${document.id}`)
} catch (error) {
console.error('[receipt-ocr] handleDocumentUploaded failed:', error)
}
}
/**
* When new transactions arrive from banking sync, auto-match unmatched receipts.
*/
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>
): Promise<void> {
const { transactions: syncedTransactions, userId } = payload
// Gate: Is autoMatchEnabled?
const settings = await getSettings(userId)
if (!settings.autoMatchEnabled) {
return
}
// Only consider expense transactions
const expenseTransactions = syncedTransactions.filter((t) => t.amount < 0)
if (expenseTransactions.length === 0) {
return
}
console.log(
`[receipt-ocr] Auto-match triggered for ${expenseTransactions.length} expense transactions`
)
try {
const supabase = await 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
)
// 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 eventBus.emit({
type: 'receipt.matched',
payload: {
receipt,
transaction: match.transaction,
confidence: match.confidence,
autoMatched: true,
userId,
},
})
console.log(
`[receipt-ocr] Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})`
)
}
} catch (error) {
console.error('[receipt-ocr] handleTransactionSynced failed:', error)
}
}
// ============================================================
// 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',
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 saveSettings(ctx.userId, DEFAULT_SETTINGS)
},
}