Files
accounted/extensions/general/ai-categorization/index.ts
T
Jakob Wennberg 13725ffc16 feat: production readiness — 3-extension deploy with security hardening and observability
- Strip extensions to enable-banking, ai-categorization, ai-chat only
- Remove push-notifications cron from vercel.json
- Add security headers (HSTS, CSP, X-Frame-Options, Permissions-Policy)
- Add /api/health endpoint for uptime monitoring
- Add env var validation in ensureInitialized()
- Fix SIE4 #IB opening balance records from year-end closing entry
- Replace in-memory ai-chat rate limiter with Supabase-backed distributed rate limiting
- Add Sentry error tracking scaffolding (@sentry/nextjs, instrumentation hook)
- Add AI token usage tracking (migration 047, usage-tracker, wired into both AI extensions)
- Include pending enable-banking and dashboard improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:32:43 +01:00

402 lines
13 KiB
TypeScript

import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { aiCategorizationApiRoutes } from './api-routes'
import type { EventPayload } from '@/lib/events/types'
import type { Transaction, EntityType } from '@/types'
import {
AnthropicCategorizationProvider,
type CategorizationProvider,
type TransactionForCategorization,
type EnrichedCategorizationContext,
type CategorizationSuggestion,
type AccountUsageEntry,
type MerchantHistoryEntry,
type TrackingContext,
} from './categorizer'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
// ============================================================
// 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',
}
/** 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
.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 { createClient } = await import('@/lib/supabase/server')
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 { createClient } = await import('@/lib/supabase/server')
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 buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
// Store suggestions
await storeSuggestions(userId, suggestions, supabase)
return suggestions
}
// ============================================================
// Event Handler
// ============================================================
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>,
ctx?: ExtensionContext
): Promise<void> {
const { transactions: syncedTransactions, userId } = payload
const log = ctx?.log ?? console
// Gate: Is autoSuggestEnabled?
const settings = ctx ? await getSettingsViaCtx(ctx) : 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
}
log.info(`Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`)
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).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 buildEnrichedContext(userId, supabase, batch)
const aiProvider = getProvider(settings.providerModel)
const suggestions = await aiProvider.categorize(batch, context, { supabase, userId })
// Store only suggestions above confidence threshold
const qualifiedSuggestions = suggestions.filter(
(s) => s.confidence >= settings.confidenceThreshold
)
if (qualifiedSuggestions.length > 0) {
await storeSuggestions(userId, qualifiedSuggestions, supabase)
}
log.info(
`Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
)
} catch (error) {
log.error('handleTransactionSynced failed:', error)
}
}
// ============================================================
// Helpers
// ============================================================
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function buildEnrichedContext(
userId: string,
supabase: any,
transactions: TransactionForCategorization[]
): Promise<EnrichedCategorizationContext> {
// 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,
})
)
// Fetch user's most-used accounts (top 30)
const { data: accountUsageRows } = await supabase
.from('journal_entry_lines')
.select('account_number')
.eq('user_id', userId)
const accountCounts = new Map<string, number>()
if (accountUsageRows) {
for (const row of accountUsageRows as { account_number: string }[]) {
accountCounts.set(row.account_number, (accountCounts.get(row.account_number) || 0) + 1)
}
}
const userAccountUsage: AccountUsageEntry[] = Array.from(accountCounts.entries())
.map(([account_number, count]) => ({ account_number, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 30)
// Fetch merchant history for this batch's merchants
const merchantNames = [...new Set(
transactions
.map((t) => t.merchant_name)
.filter((n): n is string => n !== null && n.length > 0)
)]
let merchantHistory: MerchantHistoryEntry[] = []
if (merchantNames.length > 0) {
const { data: merchantRows } = await supabase
.from('transactions')
.select('merchant_name, category, template_id')
.eq('user_id', userId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.in('merchant_name', merchantNames)
.limit(200)
if (merchantRows) {
const merchantMap = new Map<string, MerchantHistoryEntry>()
for (const row of merchantRows as { merchant_name: string; category: string; template_id: string | null }[]) {
const key = `${row.merchant_name}:${row.category}`
const existing = merchantMap.get(key)
if (existing) {
existing.count++
} else {
merchantMap.set(key, {
merchant_name: row.merchant_name,
category: row.category,
template_id: row.template_id,
count: 1,
})
}
}
merchantHistory = Array.from(merchantMap.values())
.sort((a, b) => b.count - a.count)
}
}
// Find candidate templates via embedding search
// Use a representative subset of transactions to find candidates
const { findSimilarTemplates } = await import('./lib/template-embeddings')
const representativeTransactions = transactions.slice(0, 5)
const candidateMap = new Map<string, BookingTemplate>()
for (const tx of representativeTransactions) {
try {
const matches = await findSimilarTemplates(
tx as unknown as Transaction,
entityType
)
for (const m of matches) {
if (!candidateMap.has(m.template.id)) {
candidateMap.set(m.template.id, m.template)
}
}
} catch {
// Embedding search failed — continue without candidates
}
}
const candidateTemplates = Array.from(candidateMap.values())
return {
entityType,
recentHistory,
candidateTemplates,
userAccountUsage,
merchantHistory,
}
}
async function storeSuggestions(
userId: string,
suggestions: CategorizationSuggestion[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: any
): Promise<void> {
// Group suggestions by transactionId (AI now returns multiple per transaction)
const grouped: Record<string, CategorizationSuggestion[]> = {}
for (const suggestion of suggestions) {
if (!grouped[suggestion.transactionId]) {
grouped[suggestion.transactionId] = []
}
grouped[suggestion.transactionId].push(suggestion)
}
for (const [txId, txSuggestions] of Object.entries(grouped)) {
const { error } = await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'ai-categorization',
key: `suggestion:${txId}`,
value: txSuggestions,
},
{ onConflict: 'user_id,extension_id,key' }
)
if (error) {
console.error(`[ai-categorization] Failed to store suggestion for ${txId}:`, error.message)
}
}
}
// ============================================================
// Extension Object
// ============================================================
export const aiCategorizationExtension: Extension = {
id: 'ai-categorization',
name: 'AI Kategorisering',
version: '1.0.0',
sector: 'general',
apiRoutes: aiCategorizationApiRoutes,
eventHandlers: [
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
settingsPanel: {
label: 'AI Kategorisering',
path: '/settings/extensions/ai-categorization',
},
services: {
findSimilarTemplates: async (...args: unknown[]) => {
const { findSimilarTemplates } = await import('./lib/template-embeddings')
return findSimilarTemplates(
args[0] as Transaction,
args[1] as EntityType | undefined,
args[2] as number | undefined,
args[3] as string | undefined
)
},
seedAllTemplateEmbeddings: async () => {
const { seedAllTemplateEmbeddings } = await import('./lib/template-embeddings')
return seedAllTemplateEmbeddings()
},
getSchemaVersion: async () => {
const { getSchemaVersion } = await import('./lib/template-embeddings')
return getSchemaVersion()
},
categorizeTransactions: async (...args: unknown[]) => {
return categorizeTransactions(args[0] as string, args[1] as string[])
},
analyzeDescription: async (...args: unknown[]) => {
const { analyzeDescription } = await import('./lib/description-analyzer')
return analyzeDescription(
args[0] as import('./lib/description-analyzer').DescriptionAnalysisInput
)
},
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}