feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)

Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr,
invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/
Anthropic/OpenAI deps) to simplify core and reduce bundle size.

Restructure monolithic settings page into dedicated sub-pages (company,
bookkeeping, invoicing, tax, banking, api, account, team, templates) with
shared layout and sidebar navigation.

Add atomic commit_journal_entry RPC so voucher number increment and status
update happen in a single transaction — prevents burned numbers on constraint
failures. Add continuity check report and voucher gap explanation tracking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
@@ -1,161 +0,0 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import type { CategorizationSuggestion } from './categorizer'
import { categorizeTransactions, getSettings, saveSettings } from './index'
// ============================================================
// /suggestions — GET: fetch stored suggestions
// ============================================================
async function handleGetSuggestions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const idsParam = searchParams.get('transaction_ids')
if (!idsParam) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
const transactionIds = idsParam.split(',').filter(Boolean).slice(0, 50)
// Read stored suggestions from extension_data
const keys = transactionIds.map((id) => `suggestion:${id}`)
const { data: records } = await supabase
.from('extension_data')
.select('key, value')
.eq('company_id', userId)
.eq('extension_id', 'ai-categorization')
.in('key', keys)
const suggestions: Record<string, CategorizationSuggestion> = {}
if (records) {
for (const record of records) {
const txId = record.key.replace('suggestion:', '')
suggestions[txId] = record.value as unknown as CategorizationSuggestion
}
}
return NextResponse.json({ suggestions })
}
// ============================================================
// /suggestions — POST: trigger on-demand categorization
// ============================================================
async function handlePostSuggestions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
const { transaction_ids } = body
if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) {
return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 })
}
const ids = transaction_ids.slice(0, 50)
try {
const suggestions = await categorizeTransactions(userId, ids)
// Group by transaction ID
const grouped: Record<string, CategorizationSuggestion> = {}
for (const s of suggestions) {
grouped[s.transactionId] = s
}
return NextResponse.json({ suggestions: grouped })
} catch (error) {
console.error('[ai-categorization] On-demand categorization failed:', error)
return NextResponse.json(
{ error: 'AI categorization failed' },
{ status: 500 }
)
}
}
// ============================================================
// /settings — GET: get current settings
// ============================================================
async function handleGetSettings(
_request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const settings = await getSettings(userId)
return NextResponse.json({ data: settings })
}
// ============================================================
// /settings — PUT/PATCH: update settings
// ============================================================
async function handleUpdateSettings(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
// Validate setting keys
const allowedKeys = [
'autoSuggestEnabled',
'confidenceThreshold',
'providerModel',
]
const filtered: Record<string, unknown> = {}
for (const key of allowedKeys) {
if (key in body) {
filtered[key] = body[key]
}
}
if (Object.keys(filtered).length === 0) {
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
}
const settings = await saveSettings(userId, filtered)
return NextResponse.json({ data: settings })
}
// ============================================================
// Route definitions
// ============================================================
export const aiCategorizationApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/suggestions',
handler: handleGetSuggestions,
},
{
method: 'POST',
path: '/suggestions',
handler: handlePostSuggestions,
},
{
method: 'GET',
path: '/settings',
handler: handleGetSettings,
},
{
method: 'PUT',
path: '/settings',
handler: handleUpdateSettings,
},
{
method: 'PATCH',
path: '/settings',
handler: handleUpdateSettings,
},
]
@@ -1,465 +0,0 @@
/**
* 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.
* Uses Claude Haiku with structured tool outputs for reliable JSON.
* Accepts pre-filtered candidate templates from embedding search (Tier 2)
* instead of dumping all ~100 templates into the prompt.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { TransactionCategory, EntityType } from '@/types'
import type { SupabaseClient } from '@supabase/supabase-js'
// ============================================================
// Types
// ============================================================
export interface TransactionForCategorization {
id: string
description: string
amount: number
date: string
merchant_name: string | null
mcc_code: number | null
currency: string
}
export interface AccountUsageEntry {
account_number: string
count: number
}
export interface MerchantHistoryEntry {
merchant_name: string
category: string
template_id: string | null
count: number
}
export interface CategorizationContext {
entityType: EntityType
recentHistory: { description: string; category: string }[]
}
export interface DocumentEnrichment {
type: 'receipt' | 'supplier_invoice'
merchantName?: string
lineItems?: Array<{ description: string; amount: number; category?: string; accountSuggestion?: string }>
vatBreakdown?: Array<{ rate: number; amount: number }>
isReverseCharge?: boolean
}
export interface EnrichedCategorizationContext extends CategorizationContext {
candidateTemplates: BookingTemplate[]
userAccountUsage: AccountUsageEntry[]
merchantHistory: MerchantHistoryEntry[]
documentData?: DocumentEnrichment
}
export interface CategorizationSuggestion {
transactionId: string
category: TransactionCategory
basAccount: string
taxCode: string | null
confidence: number
reasoning: string
isPrivate: boolean
templateId?: string
}
export interface TrackingContext {
supabase: SupabaseClient
userId: string
companyId?: string
}
export interface CategorizationProvider {
categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext | EnrichedCategorizationContext,
tracking?: TrackingContext
): Promise<CategorizationSuggestion[]>
}
// ============================================================
// BAS Account + Category Mapping (used in prompt)
// ============================================================
function getCategoryAccountMap(entityType: EntityType): Record<string, { account: string; label: string }> {
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991'
return {
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: educationAccount, label: 'Utbildning' },
expense_representation: { account: '6071', label: 'Representation (mat/möte)' },
expense_consumables: { account: '5460', label: 'Förbrukningsvaror' },
expense_vehicle: { account: '5611', label: 'Bil & drivmedel' },
expense_telecom: { account: '6200', label: 'Telefon & internet' },
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
expense_interest: { account: '8410', label: 'Räntekostnader' },
financial_loan_repayment: { account: '2350', label: 'Amortering lån (kreditinstitut)' },
expense_other: { account: '6991', label: 'Övriga kostnader' },
}
}
/** Fallback template IDs when AI doesn't provide one */
const CATEGORY_DEFAULT_TEMPLATES: Record<string, string> = {
expense_representation: 'representation_external',
expense_equipment: 'equipment_small',
expense_software: 'it_saas_subscription',
expense_travel: 'travel_transport',
expense_office: 'office_supplies_general',
expense_consumables: 'office_supplies_general',
expense_vehicle: 'vehicle_fuel',
expense_telecom: 'telecom_mobile',
expense_marketing: 'marketing_online_ads_eu',
expense_education: 'education_course',
expense_professional_services: 'prof_accounting',
}
/**
* Build template reference from candidate templates (pre-filtered by embeddings)
* or fall back to full template list if no candidates provided.
*/
function getTemplateReference(
direction: 'expense' | 'income',
candidateTemplates?: BookingTemplate[]
): string {
const templates = candidateTemplates && candidateTemplates.length > 0
? candidateTemplates
: BOOKING_TEMPLATES
return templates
.filter((t) => t.direction === direction || t.direction === 'transfer')
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
.join('\n')
}
const NON_DEDUCTIBLE_RULES = `
MOMSREGLER FÖR SPECIFIKA KATEGORIER:
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§), konto 6071/6072
- Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
`
// ============================================================
// Classify Transaction Tool Schema
// ============================================================
const CLASSIFY_TOOL: Anthropic.Tool = {
name: 'classify_transactions',
description: 'Classify a batch of bank transactions into Swedish BAS accounts and booking templates.',
input_schema: {
type: 'object' as const,
properties: {
suggestions: {
type: 'array',
items: {
type: 'object',
properties: {
transactionId: { type: 'string', description: 'Transaction ID' },
templateId: { type: 'string', description: 'Booking template ID (REQUIRED — must be from the provided templates list)' },
category: { type: 'string', description: 'Transaction category (e.g. expense_representation, expense_equipment, expense_office)' },
basAccount: { type: 'string', description: 'BAS account number (4 digits)' },
taxCode: { type: ['string', 'null'], description: 'Tax code: MPI for deductible expenses with VAT, MP1 for income with VAT, null for VAT-exempt/private' },
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
reasoning: { type: 'string', description: 'Short reasoning in Swedish' },
isPrivate: { type: 'boolean', description: 'Whether this is a private expense' },
},
required: ['transactionId', 'templateId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
},
},
},
required: ['suggestions'],
},
}
// ============================================================
// 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 | EnrichedCategorizationContext,
tracking?: TrackingContext
): Promise<CategorizationSuggestion[]> {
// Cap batch size
const batch = transactions.slice(0, MAX_BATCH_SIZE)
if (batch.length === 0) return []
const enriched = isEnrichedContext(context) ? context : null
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
const categoryAccountMap = getCategoryAccountMap(context.entityType)
// Build template references — use candidate templates if available
const hasExpenses = batch.some((t) => t.amount < 0)
const hasIncome = batch.some((t) => t.amount > 0)
const candidates = enriched?.candidateTemplates
const templateRef = [
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense', candidates)}` : '',
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income', candidates)}` : '',
].filter(Boolean).join('\n\n')
// Build account usage context
const accountUsageContext = enriched?.userAccountUsage && enriched.userAccountUsage.length > 0
? `\nAnvändarens mest använda konton:\n${enriched.userAccountUsage
.slice(0, 15)
.map((a) => `- ${a.account_number} (${a.count} bokningar)`)
.join('\n')}`
: ''
// Build merchant history context
const merchantHistoryContext = enriched?.merchantHistory && enriched.merchantHistory.length > 0
? `\nTidigare kategorisering av dessa handlare:\n${enriched.merchantHistory
.map((m) => `- "${m.merchant_name}" → ${m.category}${m.template_id ? ` (mall: ${m.template_id})` : ''} (${m.count}x)`)
.join('\n')}`
: ''
// Build document enrichment context (from linked receipt or supplier invoice)
const documentContext = enriched?.documentData
? buildDocumentContext(enriched.documentData)
: ''
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 mall-ID (templateId) och BAS-konto.
BOKFÖRINGSMALLAR (id: namn → debitkonto/kreditkonto):
${templateRef}
KATEGORIER (fallback om ingen mall matchar):
${Object.entries(categoryAccountMap)
.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}
${documentContext}
REGLER:
1. Negativa belopp = utgifter, positiva = intäkter
2. VIKTIGT: Dessa transaktioner kommer från företagets bankkonto/kort. Anta ALLTID att de är affärsrelaterade. Klassificera ALDRIG som "private" — det beslutet tar användaren själv.
3. Ange confidence 0.0-1.0 baserat på hur säker du är på rätt affärskategori
4. Ange kort reasoning på svenska
5. Restauranger/mat/café → expense_representation (6071). Bygghandel/järnhandel → expense_equipment eller expense_consumables. Heminredning/kontorsvaror → expense_office.
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria
7. templateId är OBLIGATORISKT — välj alltid den mest passande mallen från listan ovan, även för alternativa förslag
8. isPrivate ska ALLTID vara false — användaren avgör själv vad som är privat
9. Ange TVÅ förslag per transaktion: ett primärt (mest troligt) och ett alternativt (näst mest troligt, annan kategori, lägre confidence). Båda ska vara affärskategorier.
10. SKULDER: Konto 2440 (leverantörsskulder) ska BARA användas för leverantörsfakturor. Lån/amorteringar från banker eller kreditinstitut (Almi, Nordea, SEB, Handelsbanken, Swedbank, etc.) ska använda 2350 (skulder till kreditinstitut) via mallen financial_loan_repayment. Räntebetalningar ska använda 8410 (räntekostnader).`
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 med classify_transactions-verktyget.
Ange TVÅ förslag per transaktion (primärt + alternativ med lägre confidence):
${historyContext}${accountUsageContext}${merchantHistoryContext}
TRANSAKTIONER:
${transactionList}`
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: [
{
type: 'text',
text: systemPrompt,
cache_control: { type: 'ephemeral' },
},
],
tools: [CLASSIFY_TOOL],
tool_choice: { type: 'tool', name: 'classify_transactions' },
messages: [
{
role: 'user',
content: userPrompt,
},
],
})
// Track token usage (fire-and-forget)
if (tracking && message.usage) {
const { trackTokenUsage } = await import('@/lib/ai/usage-tracker')
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-categorization', {
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
model: this.model,
}, tracking.companyId)
}
// Extract tool_use block from response
const toolUseBlock = message.content.find(
(block) => block.type === 'tool_use' && block.name === 'classify_transactions'
)
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
throw new Error('No tool_use block in AI response')
}
const input = toolUseBlock.input as { suggestions?: unknown[] }
return this.validateSuggestions(input.suggestions || [], batch, context.entityType)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
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[],
entityType: EntityType
): CategorizationSuggestion[] {
if (!Array.isArray(raw)) return []
const categoryAccountMap = getCategoryAccountMap(entityType)
const validTransactionIds = new Set(transactions.map((t) => t.id))
const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized']))
const transactionMap = new Map(transactions.map((t) => [t.id, t]))
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) => {
// Never let AI classify as private — remap to expense_other
let category = validCategories.has(s.category as string)
? (s.category as TransactionCategory)
: 'expense_other'
if (category === 'private') {
category = 'expense_other'
}
// Enforce direction: positive amounts = income, negative = expense
const tx = transactionMap.get(s.transactionId as string)
if (tx) {
if (tx.amount > 0 && category.startsWith('expense_')) {
category = 'income_other' as TransactionCategory
} else if (tx.amount < 0 && category.startsWith('income_')) {
category = 'expense_other' as TransactionCategory
}
}
const accountInfo = categoryAccountMap[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: false,
templateId: (s.templateId as string) || CATEGORY_DEFAULT_TEMPLATES[category] || undefined,
}
})
}
}
function buildDocumentContext(doc: DocumentEnrichment): string {
const parts: string[] = []
const typeLabel = doc.type === 'receipt' ? 'KVITTO' : 'LEVERANTÖRSFAKTURA'
parts.push(`LÄNKAT DOKUMENT (${typeLabel}):`)
if (doc.merchantName) {
parts.push(`Handlare/leverantör: ${doc.merchantName}`)
}
if (doc.lineItems && doc.lineItems.length > 0) {
parts.push('Rader:')
for (const item of doc.lineItems) {
let line = `- ${item.description}: ${item.amount} kr`
if (item.accountSuggestion) line += ` (föreslaget konto: ${item.accountSuggestion})`
if (item.category) line += ` [${item.category}]`
parts.push(line)
}
}
if (doc.vatBreakdown && doc.vatBreakdown.length > 0) {
parts.push('Momsfördelning:')
for (const vat of doc.vatBreakdown) {
parts.push(`- ${vat.rate}%: ${vat.amount} kr`)
}
}
if (doc.isReverseCharge) {
parts.push(`VIKTIGT: Omvänd skattskyldighet (reverse charge). Använd dubbelkontering:
- Debitera 2645 (beräknad ingående moms) OCH kreditera 2614 (utgående moms, omvänd skattskyldighet)
- Mallen "purchase_eu_service_reverse_charge" ska användas om tillgänglig`)
}
return parts.join('\n') + '\n'
}
function isEnrichedContext(
ctx: CategorizationContext | EnrichedCategorizationContext
): ctx is EnrichedCategorizationContext {
return 'candidateTemplates' in ctx
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -1,400 +0,0 @@
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,
} 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('company_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('company_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
// ============================================================
async function buildEnrichedContext(
userId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: any,
transactions: TransactionForCategorization[]
): Promise<EnrichedCategorizationContext> {
// Fetch entity type
const { data: companySettings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_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('company_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('company_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('company_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)
},
}
@@ -1,297 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock server-only (no-op in tests)
vi.mock('server-only', () => ({}))
// Mock Anthropic SDK
const mockCreate = vi.fn()
vi.mock('@anthropic-ai/sdk', () => {
class MockAnthropic {
messages = { create: mockCreate }
}
return { default: MockAnthropic }
})
function makeToolResponse(input: Record<string, unknown>) {
return {
content: [
{
type: 'tool_use',
name: 'analyze_description',
input,
},
],
}
}
describe('description-analyzer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns correct result for an expense with standard VAT', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6071',
creditAccount: '1930',
vatTreatment: 'standard_25',
category: 'expense_representation',
confidence: 0.85,
reasoning: 'Lunch med kund klassificeras som representation.',
warnings: ['Max 300 kr/person for avdragsratt'],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Lunch med kund',
transactionAmount: -450,
transactionDate: '2026-01-15',
transactionDescription: 'RESTAURANT XYZ',
merchantName: 'Restaurant XYZ',
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.debitAccount).toBe('6071')
expect(result.creditAccount).toBe('1930')
expect(result.vatTreatment).toBe('standard_25')
expect(result.category).toBe('expense_representation')
expect(result.confidence).toBe(0.85)
expect(result.reasoning).toContain('representation')
expect(result.warnings).toHaveLength(1)
expect(result.templateId).toBeNull()
})
it('returns correct result for income', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '1930',
creditAccount: '3001',
vatTreatment: 'standard_25',
category: 'income_services',
confidence: 0.9,
reasoning: 'Konsultarvode bokförs som tjänsteintäkt.',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Konsultarvode',
transactionAmount: 25000,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT FROM CLIENT',
merchantName: null,
currency: 'SEK',
entityType: 'aktiebolag',
})
expect(result.debitAccount).toBe('1930')
expect(result.creditAccount).toBe('3001')
expect(result.category).toBe('income_services')
expect(result.confidence).toBe(0.9)
})
it('corrects category direction mismatch', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6071',
creditAccount: '1930',
vatTreatment: null,
category: 'income_services', // Wrong direction for expense
confidence: 0.7,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -500,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
// Income category should be corrected to expense for negative amount
expect(result.category).toBe('expense_other')
})
it('clamps confidence to [0, 1]', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6991',
creditAccount: '1930',
vatTreatment: null,
category: 'expense_other',
confidence: 1.5, // Over 1
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.confidence).toBe(1)
})
it('falls back to default accounts for invalid account numbers', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: 'INVALID',
creditAccount: 'bad',
vatTreatment: null,
category: 'expense_other',
confidence: 0.5,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
// Should fall back to safe defaults
expect(result.debitAccount).toBe('6991')
expect(result.creditAccount).toBe('1930')
})
it('enforces expense direction: credit account must be 1930', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '5420',
creditAccount: '2440', // Not 1930 for a bank transaction expense
vatTreatment: 'standard_25',
category: 'expense_software',
confidence: 0.8,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Software subscription',
transactionAmount: -500,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.creditAccount).toBe('1930')
})
it('rejects private category', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '2013',
creditAccount: '1930',
vatTreatment: null,
category: 'private',
confidence: 0.9,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.category).toBe('expense_other')
})
it('throws after retries exhausted', async () => {
mockCreate.mockRejectedValue(new Error('API error'))
const { analyzeDescription } = await import('../description-analyzer')
await expect(
analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
).rejects.toThrow('AI description analysis failed after 3 attempts')
// Should have retried 3 times (initial + 2 retries)
expect(mockCreate).toHaveBeenCalledTimes(3)
})
it('validates invalid VAT treatment to null', async () => {
mockCreate.mockResolvedValueOnce(
makeToolResponse({
debitAccount: '6991',
creditAccount: '1930',
vatTreatment: 'invalid_vat',
category: 'expense_other',
confidence: 0.5,
reasoning: 'Test',
warnings: [],
templateId: null,
})
)
const { analyzeDescription } = await import('../description-analyzer')
const result = await analyzeDescription({
description: 'Something',
transactionAmount: -100,
transactionDate: '2026-01-15',
transactionDescription: 'PAYMENT',
merchantName: null,
currency: 'SEK',
entityType: 'enskild_firma',
})
expect(result.vatTreatment).toBeNull()
})
})
@@ -1,233 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
import { BOOKING_TEMPLATES } from '@/lib/bookkeeping/booking-templates'
// Mock server-only (no-op in tests)
vi.mock('server-only', () => ({}))
// Mock OpenAI Embeddings
vi.mock('@langchain/openai', () => {
class MockOpenAIEmbeddings {
embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1))
embedDocuments = vi.fn().mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => new Array(1536).fill(0.1)))
)
}
return { OpenAIEmbeddings: MockOpenAIEmbeddings }
})
// Mock Supabase
const { supabase: mockSupabase, mockResult } = createMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue(mockSupabase),
}))
describe('template-embeddings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildEmbeddingText', () => {
it('includes all relevant fields for a template', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
const text = buildEmbeddingText(template)
// Should include Swedish and English name
expect(text).toContain('Lokalhyra')
expect(text).toContain('Office rent')
// Should include description
expect(text).toContain(template.description_sv)
// Should include keywords
expect(text).toContain('hyra')
expect(text).toContain('lokal')
// Should include group
expect(text).toContain('premises')
// Should include direction
expect(text).toContain('utgift')
// Should include accounts
expect(text).toContain('5010')
expect(text).toContain('1930')
})
it('includes VAT treatment when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
const text = buildEmbeddingText(template)
expect(text).toContain('standard_25')
expect(text).toContain('25%')
})
it('includes special rules when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
const text = buildEmbeddingText(template)
expect(text).toContain(template.special_rules_sv!)
})
it('includes MCC codes when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
const text = buildEmbeddingText(template)
expect(text).toContain('4900')
})
it('includes deductibility note for non-full deductibility', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.deductibility === 'non_deductible')!
const text = buildEmbeddingText(template)
expect(text).toContain('non_deductible')
})
it('generates text for all templates without error', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
for (const template of BOOKING_TEMPLATES) {
const text = buildEmbeddingText(template)
expect(text.length).toBeGreaterThan(10)
}
})
})
describe('buildTransactionQueryText', () => {
it('combines description, merchant, and direction', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
mcc_code: 5815,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('SPOTIFY PREMIUM')
expect(text).toContain('Spotify')
expect(text).toContain('MCC 5815')
expect(text).toContain('utgift')
})
it('marks positive amounts as income', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'Inbetalning',
amount: 5000,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('intäkt')
})
it('handles null merchant_name and mcc_code', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'Some payment',
merchant_name: null,
mcc_code: null,
amount: -100,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('Some payment')
expect(text).toContain('utgift')
expect(text).not.toContain('MCC')
})
it('prepends user description when provided', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SWE REST 4521 STHLM',
merchant_name: 'Unknown',
amount: -450,
})
const text = buildTransactionQueryText(tx, 'business lunch with client')
// User description should appear first
expect(text.indexOf('business lunch with client')).toBe(0)
// Transaction data should still be present
expect(text).toContain('SWE REST 4521 STHLM')
expect(text).toContain('Unknown')
expect(text).toContain('utgift')
})
it('behaves identically when userDescription is undefined', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
})
const withoutDesc = buildTransactionQueryText(tx)
const withUndefined = buildTransactionQueryText(tx, undefined)
expect(withoutDesc).toBe(withUndefined)
})
})
describe('getSchemaVersion', () => {
it('returns a consistent hash string', async () => {
const { getSchemaVersion } = await import('../template-embeddings')
const v1 = getSchemaVersion()
const v2 = getSchemaVersion()
expect(v1).toBe(v2)
expect(v1).toHaveLength(12)
expect(v1).toMatch(/^[a-f0-9]+$/)
})
})
describe('findSimilarTemplates', () => {
it('returns empty array on RPC error (graceful fallback)', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
// Mock staleness check
mockResult({ data: { schema_version: 'test' }, error: null })
const tx = makeTransaction({
description: 'SPOTIFY',
amount: -109,
})
// The mock will return error for the RPC call
mockResult({ data: null, error: { message: 'RPC failed' } })
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
})
it('returns empty array when no embeddings exist', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
mockResult({ data: [], error: null })
const tx = makeTransaction({
description: 'Random purchase',
amount: -50,
})
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
})
})
})
@@ -1,277 +0,0 @@
/**
* AI Description Analyzer
*
* SERVER-ONLY: Uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Interprets a user's plain-language description of a bank transaction
* and returns a structured booking suggestion with Swedish accounting reasoning.
* Uses Claude Haiku with structured tool outputs for reliable JSON.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { TransactionCategory, VatTreatment, EntityType } from '@/types'
// ============================================================
// Types
// ============================================================
export interface DescriptionAnalysisInput {
description: string
transactionAmount: number
transactionDate: string
transactionDescription: string
merchantName: string | null
currency: string
entityType: EntityType
}
export interface DescriptionAnalysisResult {
debitAccount: string
creditAccount: string
vatTreatment: VatTreatment | null
category: TransactionCategory
confidence: number
reasoning: string
warnings: string[]
templateId: string | null
}
// ============================================================
// Constants
// ============================================================
const MAX_RETRIES = 2
const RETRY_DELAY_MS = 500
const MODEL = 'claude-haiku-4-5-20251001'
const VALID_CATEGORIES = new Set<string>([
'income_services', 'income_products', 'income_other',
'expense_equipment', 'expense_software', 'expense_travel',
'expense_office', 'expense_marketing', 'expense_professional_services',
'expense_education', 'expense_representation', 'expense_consumables',
'expense_vehicle', 'expense_telecom', 'expense_bank_fees',
'expense_card_fees', 'expense_currency_exchange', 'expense_other',
])
const VALID_VAT_TREATMENTS = new Set<string>([
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
])
// ============================================================
// Tool Schema
// ============================================================
const ANALYZE_TOOL: Anthropic.Tool = {
name: 'analyze_description',
description: 'Analyze a user description and return a structured booking suggestion for the transaction.',
input_schema: {
type: 'object' as const,
properties: {
debitAccount: { type: 'string', description: 'BAS debit account number (4 digits)' },
creditAccount: { type: 'string', description: 'BAS credit account number (4 digits)' },
vatTreatment: {
type: ['string', 'null'],
description: 'VAT treatment: standard_25, reduced_12, reduced_6, reverse_charge, export, exempt, or null if exempt/no VAT',
},
category: { type: 'string', description: 'Transaction category (e.g. expense_representation, income_services)' },
confidence: { type: 'number', description: 'Confidence score 0.0-1.0' },
reasoning: { type: 'string', description: 'Explanation in Swedish of why this booking is correct' },
warnings: {
type: 'array',
items: { type: 'string' },
description: 'Warnings about deductibility limits, special rules, etc. (in Swedish)',
},
templateId: {
type: ['string', 'null'],
description: 'Matching booking template ID if applicable, or null',
},
},
required: ['debitAccount', 'creditAccount', 'vatTreatment', 'category', 'confidence', 'reasoning', 'warnings', 'templateId'],
},
}
// ============================================================
// System Prompt
// ============================================================
function buildSystemPrompt(entityType: EntityType): string {
const privateAccount = entityType === 'aktiebolag' ? '2893' : '2013'
const entityLabel = entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'
return `Du är expert på svensk bokföring enligt BAS-kontoplanen. Analysera användarens beskrivning av en banktransaktion och returnera ett bokföringsförslag.
VANLIGA BAS-KONTON:
Utgifter: 5010 Lokalhyra | 5410 Förbrukningsinventarier | 5420 Programvara | 5460 Förbrukningsvaror | 5611 Bil/drivmedel | 5800 Resekostnader | 5910 Annonsering | 6071 Representation mat | 6200 Telefon/internet | 6530 Redovisning/konsult | 6570 Bankavgifter | 6991 Övriga kostnader | ${entityType === 'aktiebolag' ? '7610' : '6991'} Utbildning
Intäkter: 3001 Försäljning 25% | 3002 Försäljning 12% | 3003 Försäljning 6% | 3305 Export | 3308 EU-tjänster | 3900 Övriga intäkter
Moms: 2611 Utg moms 25% | 2621 Utg moms 12% | 2631 Utg moms 6% | 2641 Ing moms | 2645 Beräknad ing moms
Skulder: 2350 Skulder till kreditinstitut (banklån, Almi) | 2440 Leverantörsskulder (ENBART för leverantörsfakturor)
Övrigt: 1510 Kundfordringar | 1930 Företagskonto | 8410 Räntekostnader | ${privateAccount} Privat
MOMSREGLER:
- standard_25: Normala varor/tjänster (25%)
- reduced_12: Livsmedel, hotell, konstverk (12%)
- reduced_6: Böcker, tidningar, kollektivtrafik, kultur (6%)
- reverse_charge: Tjänsteköp från utlandet/EU
- export: Försäljning utanför Sverige
- exempt: Momsfritt (bank, försäkring, sjukvård, utbildning)
VARNINGSREGLER:
- Representation/måltider: Max 300 kr/person exkl moms för avdragsrätt (IL 16 kap 2§)
- Gåvor: Reklamgåvor max 300 kr, representationsgåvor max 180 kr
- Blandad användning (telefon/dator): Bara yrkesmässig del avdragsgill
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA (exempt)
Företagsform: ${entityLabel}
Privatkonto: ${privateAccount}
REGLER:
1. Negativt belopp = utgift: debitera kostnadskonto, kreditera 1930
2. Positivt belopp = intäkt: debitera 1930, kreditera intäktskonto
3. Ge ett klart reasoning på svenska som förklarar valet
4. Lägg till warnings för avdragsbegränsningar eller speciella regler
5. templateId: null (vi matchar mallar separat)`
}
// ============================================================
// Analyzer
// ============================================================
export async function analyzeDescription(
input: DescriptionAnalysisInput
): Promise<DescriptionAnalysisResult> {
const client = new Anthropic()
const isExpense = input.transactionAmount < 0
const userPrompt = `Transaktion:
- Användarens beskrivning: "${input.description}"
- Banktext: "${input.transactionDescription}"
- Belopp: ${input.transactionAmount} ${input.currency}
- Datum: ${input.transactionDate}${input.merchantName ? `\n- Handlare: ${input.merchantName}` : ''}
Analysera och returnera bokföringsförslag med analyze_description-verktyget.`
let lastError: Error | null = null
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const message = await client.messages.create({
model: MODEL,
max_tokens: 1024,
system: [
{
type: 'text',
text: buildSystemPrompt(input.entityType),
cache_control: { type: 'ephemeral' },
},
],
tools: [ANALYZE_TOOL],
tool_choice: { type: 'tool', name: 'analyze_description' },
messages: [{ role: 'user', content: userPrompt }],
})
const toolUseBlock = message.content.find(
(block) => block.type === 'tool_use' && block.name === 'analyze_description'
)
if (!toolUseBlock || toolUseBlock.type !== 'tool_use') {
throw new Error('No tool_use block in AI response')
}
return validateResult(toolUseBlock.input as Record<string, unknown>, isExpense, input.entityType)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(
`AI description analysis failed after ${MAX_RETRIES + 1} attempts: ${lastError?.message}`
)
}
// ============================================================
// Validation
// ============================================================
function validateResult(
raw: Record<string, unknown>,
isExpense: boolean,
entityType: EntityType
): DescriptionAnalysisResult {
const ACCOUNT_REGEX = /^\d{4}$/
// Validate accounts — default to safe fallbacks
let debitAccount = typeof raw.debitAccount === 'string' && ACCOUNT_REGEX.test(raw.debitAccount)
? raw.debitAccount
: (isExpense ? '6991' : '1930')
let creditAccount = typeof raw.creditAccount === 'string' && ACCOUNT_REGEX.test(raw.creditAccount)
? raw.creditAccount
: (isExpense ? '1930' : '3001')
// Enforce direction: expenses debit expense account + credit 1930, income debit 1930 + credit revenue
if (isExpense && creditAccount !== '1930') {
creditAccount = '1930'
}
if (!isExpense && debitAccount !== '1930') {
debitAccount = '1930'
}
// Validate VAT treatment
const rawVat = raw.vatTreatment as string | null
const vatTreatment = rawVat && VALID_VAT_TREATMENTS.has(rawVat)
? rawVat as VatTreatment
: null
// Validate category with direction correction
let category = VALID_CATEGORIES.has(raw.category as string)
? (raw.category as TransactionCategory)
: (isExpense ? 'expense_other' : 'income_other')
if (category === 'private') {
category = isExpense ? 'expense_other' : 'income_other'
}
if (isExpense && category.startsWith('income_')) {
category = 'expense_other'
}
if (!isExpense && category.startsWith('expense_')) {
category = 'income_other'
}
// Clamp confidence
const confidence = Math.max(0, Math.min(1, Number(raw.confidence) || 0.5))
// Reasoning — must be a non-empty string
const reasoning = typeof raw.reasoning === 'string' && raw.reasoning.length > 0
? raw.reasoning
: (isExpense ? 'Utgift bokförd på standardkonto' : 'Intäkt bokförd på standardkonto')
// Warnings
const warnings = Array.isArray(raw.warnings)
? (raw.warnings as unknown[]).filter((w): w is string => typeof w === 'string')
: []
// Template ID
const templateId = typeof raw.templateId === 'string' && raw.templateId.length > 0
? raw.templateId
: null
return {
debitAccount,
creditAccount,
vatTreatment,
category,
confidence,
reasoning,
warnings,
templateId,
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -1,265 +0,0 @@
/**
* Template Embeddings Module
*
* SERVER-ONLY: Uses OpenAI embeddings and Supabase service client.
*
* Provides semantic search over booking templates using pgvector.
* Templates are pre-embedded and stored in the database. Transaction
* text is embedded at query time and compared via cosine similarity.
*/
import 'server-only'
import { OpenAIEmbeddings } from '@langchain/openai'
import {
BOOKING_TEMPLATES,
getTemplateById,
type BookingTemplate,
type TemplateMatch,
} from '@/lib/bookkeeping/booking-templates'
import type { Transaction, EntityType } from '@/types'
import { createHash } from 'crypto'
// ============================================================
// Constants
// ============================================================
export const EMBEDDING_MODEL = 'text-embedding-3-small'
const EMBEDDING_LOGIC_VERSION = '1'
const MATCH_COUNT = 5
const MATCH_THRESHOLD = 0.5
/**
* Schema version is a hash of the model + embedding logic version.
* Bump EMBEDDING_LOGIC_VERSION when buildEmbeddingText changes.
*/
export function getSchemaVersion(): string {
return createHash('sha256')
.update(`${EMBEDDING_MODEL}:${EMBEDDING_LOGIC_VERSION}`)
.digest('hex')
.slice(0, 12)
}
// ============================================================
// Embedding Text Builders
// ============================================================
/**
* Build a rich text representation of a template for embedding.
* Includes all semantically relevant fields.
*/
export function buildEmbeddingText(template: BookingTemplate): string {
const parts: string[] = []
parts.push(`${template.name_sv} (${template.name_en})`)
parts.push(template.description_sv)
if (template.keywords.length > 0) {
parts.push(`Nyckelord: ${template.keywords.join(', ')}`)
}
parts.push(`Grupp: ${template.group}`)
parts.push(`Typ: ${template.direction === 'expense' ? 'utgift' : template.direction === 'income' ? 'intäkt' : 'överföring'}`)
parts.push(`Konton: ${template.debit_account} (debet) / ${template.credit_account} (kredit)`)
if (template.vat_treatment) {
parts.push(`Moms: ${template.vat_treatment} (${template.vat_rate * 100}%)`)
}
if (template.special_rules_sv) {
parts.push(`Regler: ${template.special_rules_sv}`)
}
if (template.mcc_codes.length > 0) {
parts.push(`MCC-koder: ${template.mcc_codes.join(', ')}`)
}
if (template.deductibility !== 'full') {
parts.push(`Avdragsrätt: ${template.deductibility}`)
}
return parts.join('. ')
}
/**
* Build query text from a transaction for embedding search.
* When userDescription is provided, it is prepended so it dominates
* the semantic search (user intent > raw bank text).
*/
export function buildTransactionQueryText(
transaction: Transaction,
userDescription?: string
): string {
const parts: string[] = []
if (userDescription) {
parts.push(userDescription)
}
if (transaction.description) {
parts.push(transaction.description)
}
if (transaction.merchant_name) {
parts.push(transaction.merchant_name)
}
if (transaction.mcc_code) {
parts.push(`MCC ${transaction.mcc_code}`)
}
parts.push(transaction.amount < 0 ? 'utgift' : 'intäkt')
return parts.join(' — ')
}
// ============================================================
// Embeddings Client
// ============================================================
let embeddingsInstance: OpenAIEmbeddings | null = null
function getEmbeddingsClient(): OpenAIEmbeddings {
if (!embeddingsInstance) {
embeddingsInstance = new OpenAIEmbeddings({
modelName: EMBEDDING_MODEL,
openAIApiKey: process.env.OPENAI_API_KEY,
})
}
return embeddingsInstance
}
// ============================================================
// Seed All Template Embeddings
// ============================================================
export async function seedAllTemplateEmbeddings(): Promise<{
seeded: number
errors: string[]
}> {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
const schemaVersion = getSchemaVersion()
const errors: string[] = []
// Build texts for all templates
const texts = BOOKING_TEMPLATES.map((t) => buildEmbeddingText(t))
// Batch embed all texts
let vectors: number[][]
try {
vectors = await embeddings.embedDocuments(texts)
} catch (error) {
return { seeded: 0, errors: [`Embedding generation failed: ${error}`] }
}
// Upsert each template embedding
let seeded = 0
for (let i = 0; i < BOOKING_TEMPLATES.length; i++) {
const template = BOOKING_TEMPLATES[i]
const { error } = await supabase
.from('booking_template_embeddings')
.upsert(
{
template_id: template.id,
embedding: JSON.stringify(vectors[i]),
embedding_text: texts[i],
model: EMBEDDING_MODEL,
schema_version: schemaVersion,
},
{ onConflict: 'template_id' }
)
if (error) {
errors.push(`Failed to upsert ${template.id}: ${error.message}`)
} else {
seeded++
}
}
return { seeded, errors }
}
// ============================================================
// Find Similar Templates (Semantic Search)
// ============================================================
let stalenessWarned = false
export async function findSimilarTemplates(
transaction: Transaction,
entityType?: EntityType,
matchCount: number = MATCH_COUNT,
userDescription?: string
): Promise<TemplateMatch[]> {
try {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
// Check schema version staleness on first call
if (!stalenessWarned) {
const { data: sample } = await supabase
.from('booking_template_embeddings')
.select('schema_version')
.limit(1)
.single()
if (sample && sample.schema_version !== getSchemaVersion()) {
console.warn(
`[template-embeddings] Schema version mismatch: DB has "${sample.schema_version}", current is "${getSchemaVersion()}". Re-seed embeddings.`
)
}
stalenessWarned = true
}
// Embed the transaction query text
const queryText = buildTransactionQueryText(transaction, userDescription)
const queryVector = await embeddings.embedQuery(queryText)
// Request extra results to account for post-filtering
const requestCount = matchCount + 10
const { data, error } = await supabase.rpc('match_booking_templates', {
query_embedding: JSON.stringify(queryVector),
match_count: requestCount,
match_threshold: MATCH_THRESHOLD,
})
if (error || !data) {
console.error('[template-embeddings] RPC error:', error)
return []
}
// Map RPC results to TemplateMatch[], filtering by entity type and direction
const isExpense = transaction.amount < 0
const isIncome = transaction.amount > 0
const results: TemplateMatch[] = []
for (const row of data as { template_id: string; similarity: number }[]) {
const template = getTemplateById(row.template_id)
if (!template) continue
// Filter by entity applicability
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) {
continue
}
// Filter by direction
if (template.direction === 'expense' && !isExpense) continue
if (template.direction === 'income' && !isIncome) continue
results.push({
template,
confidence: Math.round(row.similarity * 100) / 100,
})
if (results.length >= matchCount) break
}
return results
} catch (error) {
console.error('[template-embeddings] findSimilarTemplates failed:', error)
return []
}
}
@@ -1,19 +0,0 @@
{
"id": "ai-categorization",
"sector": "general",
"exportName": "aiCategorizationExtension",
"entryPoint": "@/extensions/general/ai-categorization",
"workspace": "@/components/extensions/general/AiCategorizationWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk", "@langchain/openai"],
"definition": {
"name": "AI-kategorisering",
"category": "operations",
"icon": "Wand",
"dataPattern": "core",
"readsCoreTables": ["transactions"],
"description": "AI-drivna kategoriförslag för transaktioner",
"longDescription": "Använder AI för att automatiskt föreslå BAS-kontokategorier för dina banktransaktioner. Lär sig från dina tidigare bokföringsval."
}
}
-634
View File
@@ -1,634 +0,0 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { generateChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
// Distributed rate limiting backed by Supabase extension_data table
import type { SupabaseClient } from '@supabase/supabase-js'
interface RateLimitState {
count: number
window_start: number
}
async function checkRateLimitDB(supabase: SupabaseClient, userId: string): Promise<boolean> {
const now = Date.now()
const WINDOW_MS = 60000
const { data } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', userId)
.eq('extension_id', 'ai-chat')
.eq('key', 'rate_limit')
.single()
const state = data?.value as RateLimitState | null
if (!state || now - state.window_start > WINDOW_MS) {
// New window
await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'ai-chat',
key: 'rate_limit',
value: { count: 1, window_start: now },
},
{ onConflict: 'user_id,extension_id,key' }
)
return true
}
if (state.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
return false
}
// Increment count
await supabase.from('extension_data').upsert(
{
user_id: userId,
extension_id: 'ai-chat',
key: 'rate_limit',
value: { count: state.count + 1, window_start: state.window_start },
},
{ onConflict: 'user_id,extension_id,key' }
)
return true
}
// ============================================================
// POST / — Send a message and get a response
// ============================================================
async function handlePostChat(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
// Rate limiting
if (!await checkRateLimitDB(supabase, userId)) {
return NextResponse.json(
{ error: 'Rate limit exceeded. Please wait a moment.' },
{ status: 429 }
)
}
try {
const body: ChatRequest = await request.json()
const { message, session_id } = body
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return NextResponse.json(
{ error: 'Message is required' },
{ status: 400 }
)
}
let sessionId = session_id
// Create new session if not provided
if (!sessionId) {
const { data: newSession, error: sessionError } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: message.slice(0, 100), // Use first 100 chars of message as title
})
.select()
.single()
if (sessionError) {
console.error('Error creating session:', sessionError)
return NextResponse.json(
{ error: 'Failed to create chat session' },
{ status: 500 }
)
}
sessionId = newSession.id
} else {
// Verify session belongs to user
const { data: existingSession } = await supabase
.from('chat_sessions')
.select('id')
.eq('id', sessionId)
.eq('company_id', userId)
.single()
if (!existingSession) {
return NextResponse.json(
{ error: 'Session not found' },
{ status: 404 }
)
}
}
// Save user message
const { error: userMsgError } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'user',
content: message.trim(),
sources: [],
})
.select()
.single()
if (userMsgError) {
console.error('Error saving user message:', userMsgError)
return NextResponse.json(
{ error: 'Failed to save message' },
{ status: 500 }
)
}
// Get conversation history
const { data: history } = await supabase
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
.limit(CHATBOT_CONFIG.maxHistoryMessages)
const conversationHistory = (history || []) as ChatMessage[]
// Generate AI response
const result = await generateChatResponse(message.trim(), conversationHistory, { supabase, userId })
// Save assistant message
const { data: assistantMessage, error: assistantMsgError } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'assistant',
content: result.content,
sources: result.sources,
})
.select()
.single()
if (assistantMsgError) {
console.error('Error saving assistant message:', assistantMsgError)
return NextResponse.json(
{ error: 'Failed to save response' },
{ status: 500 }
)
}
return NextResponse.json({
message: assistantMessage,
session_id: sessionId,
})
} catch (err) {
console.error('Chat error:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
{ status: 500 }
)
}
}
// ============================================================
// POST /stream — Streaming chat response via Server-Sent Events
// ============================================================
async function handlePostStream(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
if (!await checkRateLimitDB(supabase, userId)) {
return new Response(
JSON.stringify({ error: 'Rate limit exceeded' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
)
}
try {
const body: ChatRequest = await request.json()
const { message, session_id } = body
if (!message || typeof message !== 'string' || message.trim().length === 0) {
return new Response(
JSON.stringify({ error: 'Message is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
let sessionId = session_id
// Create new session if not provided
if (!sessionId) {
const { data: newSession, error: sessionError } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: message.slice(0, 100),
})
.select()
.single()
if (sessionError) {
return new Response(
JSON.stringify({ error: 'Failed to create session' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
sessionId = newSession.id
} else {
// Verify session belongs to user
const { data: existingSession } = await supabase
.from('chat_sessions')
.select('id')
.eq('id', sessionId)
.eq('company_id', userId)
.single()
if (!existingSession) {
return new Response(
JSON.stringify({ error: 'Session not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
)
}
}
// Save user message
await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'user',
content: message.trim(),
sources: [],
})
// Get conversation history
const { data: history } = await supabase
.from('chat_messages')
.select('role, content')
.eq('session_id', sessionId)
.order('created_at', { ascending: true })
.limit(CHATBOT_CONFIG.maxHistoryMessages)
const conversationHistory = (history || []) as ChatMessage[]
// Create streaming response
const encoder = new TextEncoder()
let fullContent = ''
let sources: SourceReference[] = []
let artifact: ArtifactSpec | null = null
const stream = new ReadableStream({
async start(controller) {
try {
// Send session ID first
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
)
// Stream the routed response (handles knowledge, data, and hybrid)
for await (const event of streamRoutedResponse(
message.trim(),
conversationHistory,
supabase,
userId,
sessionId
)) {
if (event.type === 'content') {
fullContent += event.content
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: event.content })}\n\n`)
)
} else if (event.type === 'sources') {
sources = event.sources
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: event.sources })}\n\n`)
)
} else if (event.type === 'tool_start') {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'tool_start', toolName: event.toolName })}\n\n`)
)
} else if (event.type === 'artifact') {
artifact = event.artifact
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'artifact', artifact: event.artifact })}\n\n`)
)
}
}
// Save the complete assistant message (including artifact)
const { data: savedMessage } = await supabase
.from('chat_messages')
.insert({
session_id: sessionId,
user_id: userId,
role: 'assistant',
content: fullContent,
sources,
...(artifact ? { artifact } : {}),
})
.select()
.single()
// Send done signal with message ID
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
)
controller.close()
} catch (error) {
console.error('Streaming error:', error)
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
)
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (err) {
console.error('Stream setup error:', err)
return new Response(
JSON.stringify({ error: 'Failed to setup stream' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
}
// ============================================================
// GET /sessions — List all chat sessions for the user
// ============================================================
async function handleGetSessions(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const limit = parseInt(searchParams.get('limit') || '20')
const offset = parseInt(searchParams.get('offset') || '0')
const { data, error, count } = await supabase
.from('chat_sessions')
.select('*', { count: 'exact' })
.eq('company_id', userId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (error) {
console.error('Error fetching sessions:', error)
return NextResponse.json(
{ error: 'Failed to fetch sessions' },
{ status: 500 }
)
}
return NextResponse.json({ data, count })
}
// ============================================================
// POST /sessions — Create a new chat session
// ============================================================
async function handlePostSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
try {
const body = await request.json()
const { title } = body
const { data, error } = await supabase
.from('chat_sessions')
.insert({
user_id: userId,
title: title || null,
})
.select()
.single()
if (error) {
console.error('Error creating session:', error)
return NextResponse.json(
{ error: 'Failed to create session' },
{ status: 500 }
)
}
return NextResponse.json({ data })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create session' },
{ status: 400 }
)
}
}
// ============================================================
// GET /sessions/:id — Get a single chat session with messages
// ============================================================
async function handleGetSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
// Get session with messages
const { data: session, error: sessionError } = await supabase
.from('chat_sessions')
.select('*')
.eq('id', id)
.eq('company_id', userId)
.single()
if (sessionError || !session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
// Get messages
const { data: messages, error: messagesError } = await supabase
.from('chat_messages')
.select('*')
.eq('session_id', id)
.order('created_at', { ascending: true })
if (messagesError) {
console.error('Error fetching messages:', messagesError)
return NextResponse.json(
{ error: 'Failed to fetch messages' },
{ status: 500 }
)
}
return NextResponse.json({
session,
messages: messages || [],
})
}
// ============================================================
// PATCH /sessions/:id — Update a chat session (e.g., rename)
// ============================================================
async function handlePatchSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
try {
const body = await request.json()
const { title } = body
const { data, error } = await supabase
.from('chat_sessions')
.update({ title })
.eq('id', id)
.eq('company_id', userId)
.select()
.single()
if (error) {
console.error('Error updating session:', error)
return NextResponse.json(
{ error: 'Failed to update session' },
{ status: 500 }
)
}
if (!data) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
return NextResponse.json({ data })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to update session' },
{ status: 400 }
)
}
}
// ============================================================
// DELETE /sessions/:id — Delete a chat session and its messages
// ============================================================
async function handleDeleteSession(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Session ID is required' }, { status: 400 })
}
// Delete session (messages will cascade delete due to FK)
const { error } = await supabase
.from('chat_sessions')
.delete()
.eq('id', id)
.eq('company_id', userId)
if (error) {
console.error('Error deleting session:', error)
return NextResponse.json(
{ error: 'Failed to delete session' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
}
// ============================================================
// Route definitions
// ============================================================
export const aiChatApiRoutes: ApiRouteDefinition[] = [
{
method: 'POST',
path: '/',
handler: handlePostChat,
},
{
method: 'POST',
path: '/stream',
handler: handlePostStream,
},
{
method: 'GET',
path: '/sessions',
handler: handleGetSessions,
},
{
method: 'POST',
path: '/sessions',
handler: handlePostSession,
},
{
method: 'GET',
path: '/sessions/:id',
handler: handleGetSession,
},
{
method: 'PATCH',
path: '/sessions/:id',
handler: handlePatchSession,
},
{
method: 'DELETE',
path: '/sessions/:id',
handler: handleDeleteSession,
},
]
-133
View File
@@ -1,133 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { createReactAgent } from '@langchain/langgraph/prebuilt'
import { HumanMessage, AIMessage } from '@langchain/core/messages'
import type { StructuredToolInterface } from '@langchain/core/tools'
import { CHATBOT_CONFIG } from './config'
import {
SYSTEM_PROMPT_DATA,
SYSTEM_PROMPT_HYBRID,
formatConversationHistory,
} from './prompts'
import type { ChatMessage } from '@/types/chat'
import type { RouteType } from './router'
export interface AgentStreamEvent {
type: 'tool_start' | 'content' | 'done'
toolName?: string
content?: string
toolResults?: ToolResultEntry[]
}
export interface ToolResultEntry {
toolName: string
result: string
}
/**
* Run the LangGraph agent with tool calling and stream events.
*/
export async function* streamAgentResponse(options: {
query: string
route: RouteType
tools: StructuredToolInterface[]
conversationHistory: ChatMessage[]
ragContext?: string
}): AsyncGenerator<AgentStreamEvent> {
const { query, route, tools, conversationHistory, ragContext } = options
// Build system prompt based on route
const historyText = formatConversationHistory(
conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages).map((m) => ({
role: m.role,
content: m.content,
}))
)
let systemPrompt: string
if (route === 'data') {
systemPrompt = SYSTEM_PROMPT_DATA.replace('{history}', historyText)
} else {
const context = ragContext || 'Ingen specifik kontext hittades i kunskapsbasen.'
systemPrompt = SYSTEM_PROMPT_HYBRID
.replace('{context}', context)
.replace('{history}', historyText)
}
// Create the model
const model = new ChatAnthropic({
modelName: CHATBOT_CONFIG.agentModel,
maxTokens: CHATBOT_CONFIG.agentMaxTokens,
temperature: CHATBOT_CONFIG.temperature,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
// Create the agent
const agent = createReactAgent({
llm: model,
tools,
prompt: systemPrompt,
})
// Build input messages
const messages: (HumanMessage | AIMessage)[] = []
// Add recent history as messages for the agent
const recent = conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages)
for (const msg of recent) {
if (msg.role === 'user') {
messages.push(new HumanMessage(msg.content))
} else {
messages.push(new AIMessage(msg.content))
}
}
messages.push(new HumanMessage(query))
// Track tool results for artifact generation
const toolResults: ToolResultEntry[] = []
// Stream the agent execution using streamEvents for fine-grained control
const eventStream = agent.streamEvents(
{ messages },
{
version: 'v2',
recursionLimit: CHATBOT_CONFIG.maxAgentIterations * 2 + 1,
}
)
for await (const event of eventStream) {
// Tool start events
if (event.event === 'on_tool_start') {
yield { type: 'tool_start', toolName: event.name }
}
// Tool end events — capture results
if (event.event === 'on_tool_end') {
const output = event.data?.output
const result = typeof output === 'string' ? output : JSON.stringify(output ?? '')
toolResults.push({
toolName: event.name,
result,
})
}
// LLM streaming tokens (final response text)
if (event.event === 'on_chat_model_stream') {
const chunk = event.data?.chunk
if (chunk) {
const content = typeof chunk.content === 'string'
? chunk.content
: Array.isArray(chunk.content)
? chunk.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text)
.join('')
: ''
if (content) {
yield { type: 'content', content }
}
}
}
}
yield { type: 'done', toolResults }
}
@@ -1,237 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { z } from 'zod'
import { CHATBOT_CONFIG } from './config'
import type { ToolResultEntry } from './agent'
import type { ArtifactSpec } from '@/types/chat'
// ── Artifact Zod Schemas ────────────────────────────────────────
const ChartDataPoint = z.object({
label: z.string(),
value: z.number(),
color: z.string().optional(),
})
const ChartArtifact = z.object({
type: z.enum(['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']),
title: z.string(),
data: z.array(ChartDataPoint),
unit: z.string().optional(),
subtitle: z.string().optional(),
})
const TableColumn = z.object({
key: z.string(),
label: z.string(),
align: z.enum(['left', 'right']).optional(),
})
const TableArtifact = z.object({
type: z.literal('table'),
title: z.string(),
columns: z.array(TableColumn),
rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
summary_row: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
})
const KpiCard = z.object({
label: z.string(),
value: z.string(),
trend: z.enum(['up', 'down', 'flat']).optional(),
change: z.string().optional(),
})
const KpiCardsArtifact = z.object({
type: z.literal('kpi_cards'),
title: z.string().optional(),
cards: z.array(KpiCard),
})
const AgingBucket = z.object({
label: z.string(),
amount: z.number(),
count: z.number(),
})
const AgingBucketsArtifact = z.object({
type: z.literal('aging_buckets'),
title: z.string(),
buckets: z.array(AgingBucket),
total: z.number(),
})
export const ArtifactSpecSchema = z.discriminatedUnion('type', [
ChartArtifact,
TableArtifact,
KpiCardsArtifact,
AgingBucketsArtifact,
])
export type { ArtifactSpec } from '@/types/chat'
// ── Artifact System Prompt ──────────────────────────────────────
const ARTIFACT_SYSTEM_PROMPT = `You are a data visualization expert. Given tool results and an AI response about accounting data, generate a structured artifact spec for visual display.
## EXACT schemas (follow field names precisely):
### Chart (bar_chart, line_chart, pie_chart, stacked_bar):
{"type":"bar_chart","title":"...","data":[{"label":"Category name","value":1234}],"unit":"kr"}
IMPORTANT: Each item in "data" MUST have "label" (string) and "value" (number). NOT "name", NOT "amount" — use exactly "label" and "value".
### Table:
{"type":"table","title":"...","columns":[{"key":"col1","label":"Header","align":"right"}],"rows":[{"col1":"value"}],"summary_row":{"col1":"Total"}}
### KPI cards:
{"type":"kpi_cards","title":"...","cards":[{"label":"Metric","value":"1 234 kr","trend":"up","change":"+12%"}]}
IMPORTANT: "trend" MUST be exactly "up", "down", or "flat". No other values allowed.
### Aging buckets:
{"type":"aging_buckets","title":"...","buckets":[{"label":"0 dagar","amount":1000,"count":2}],"total":5000}
## Rules:
1. Return ONLY a single JSON object (not an array!) or the word "null". The top-level must be an object with a "type" field.
2. Choose chart type based on data:
- Income/balance sheet sections → "bar_chart"
- Distribution (VAT, account classes) → "pie_chart"
- Company overview → "kpi_cards"
- AR/AP aging → "aging_buckets"
- Lists with >3 items + amounts → "table"
- Simple answers, few items, yes/no → null
3. Use Swedish labels. Use "kr" as unit for monetary charts.
4. Max 12 chart data points. Aggregate small items as "Övrigt".
5. For tables, include summary_row with totals where appropriate.`
// ── Normalizer ──────────────────────────────────────────────────
/**
* Fix common LLM field name mistakes before Zod validation.
* Mutates the object in place.
*/
function normalizeArtifact(obj: Record<string, unknown>): void {
if (!obj || typeof obj !== 'object') return
// Chart types: normalize data[].name→label, data[].amount→value
const chartTypes = ['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']
if (chartTypes.includes(obj.type as string) && Array.isArray(obj.data)) {
for (const item of obj.data) {
if (item && typeof item === 'object') {
if ('name' in item && !('label' in item)) {
item.label = item.name
delete item.name
}
if ('amount' in item && !('value' in item)) {
item.value = item.amount
delete item.amount
}
if ('total' in item && !('value' in item)) {
item.value = item.total
delete item.total
}
if ('value' in item && typeof item.value === 'string') {
const num = parseFloat(String(item.value).replace(/\s/g, '').replace(',', '.'))
if (!isNaN(num)) item.value = num
}
}
}
}
// KPI cards: normalize trend values
if (obj.type === 'kpi_cards' && Array.isArray(obj.cards)) {
const trendMap: Record<string, string> = {
neutral: 'flat', stable: 'flat', none: 'flat', '-': 'flat',
negative: 'down', decrease: 'down', declining: 'down',
positive: 'up', increase: 'up', increasing: 'up', growing: 'up',
}
for (const card of obj.cards) {
if (card && typeof card === 'object' && 'trend' in card) {
const t = String(card.trend).toLowerCase()
if (trendMap[t]) {
card.trend = trendMap[t]
} else if (t !== 'up' && t !== 'down' && t !== 'flat') {
// Unknown trend value — remove it so optional field passes
delete card.trend
}
}
}
}
}
// ── Generator ───────────────────────────────────────────────────
/**
* Generate an artifact spec from tool results using a post-processing LLM call.
* Returns null if no visualization is appropriate.
*/
export async function generateArtifact(
toolResults: ToolResultEntry[],
assistantResponse: string
): Promise<ArtifactSpec | null> {
if (toolResults.length === 0) return null
const model = new ChatAnthropic({
modelName: CHATBOT_CONFIG.artifactModel,
maxTokens: 1024,
temperature: 0,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
const toolSummary = toolResults
.map((r) => `Tool: ${r.toolName}\nResult: ${r.result.slice(0, 2000)}`)
.join('\n\n---\n\n')
const prompt = `${ARTIFACT_SYSTEM_PROMPT}
## Tool results:
${toolSummary}
## AI response:
${assistantResponse.slice(0, 1000)}
Generate the artifact JSON or "null":`
try {
const response = await model.invoke(prompt)
const text = typeof response.content === 'string'
? response.content
: JSON.stringify(response.content)
const trimmed = text.trim()
if (trimmed === 'null' || trimmed === '"null"') return null
// Extract JSON from response (handle markdown code blocks)
let jsonStr = trimmed
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/)
if (codeBlockMatch) {
jsonStr = codeBlockMatch[1].trim()
}
let parsed = JSON.parse(jsonStr)
// If LLM returned an array, try to wrap it as kpi_cards
if (Array.isArray(parsed)) {
// Array of cards → wrap as kpi_cards
if (parsed.length > 0 && parsed[0] && typeof parsed[0] === 'object' && 'label' in parsed[0]) {
parsed = { type: 'kpi_cards', title: 'Översikt', cards: parsed }
} else {
console.warn('Artifact returned unexpected array')
return null
}
}
// Normalize common LLM field name mistakes before validation
normalizeArtifact(parsed)
const validated = ArtifactSpecSchema.safeParse(parsed)
if (validated.success) {
return validated.data as ArtifactSpec
}
console.warn('Artifact validation failed:', validated.error.issues)
return null
} catch (e) {
console.warn('Artifact generation failed:', e)
return null
}
}
-250
View File
@@ -1,250 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { CHATBOT_CONFIG } from './config'
import {
SYSTEM_PROMPT,
formatContextFromSources,
formatConversationHistory,
} from './prompts'
import {
retrieveRelevantDocuments,
documentsToSources,
} from './retriever'
import { routeMessage, type RouteType } from './router'
import { createAccountingTools } from './tools'
import { streamAgentResponse, type ToolResultEntry } from './agent'
import { generateArtifact, type ArtifactSpec } from './artifacts'
import type { ChatMessage, SourceReference } from '@/types/chat'
import type { SupabaseClient } from '@supabase/supabase-js'
import { trackTokenUsage } from '@/lib/ai/usage-tracker'
// Initialize the LLM
function getChatModel() {
return new ChatAnthropic({
modelName: CHATBOT_CONFIG.model,
maxTokens: CHATBOT_CONFIG.maxTokens,
temperature: CHATBOT_CONFIG.temperature,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
}
export interface ChatResult {
content: string
sources: SourceReference[]
}
export async function generateChatResponse(
userMessage: string,
conversationHistory: ChatMessage[],
tracking?: { supabase: SupabaseClient; userId: string; companyId?: string }
): Promise<ChatResult> {
// 1. Retrieve relevant documents
const relevantDocs = await retrieveRelevantDocuments(userMessage)
// 2. Format context from retrieved documents
const context = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
// 3. Format conversation history (last N messages)
const recentHistory = conversationHistory.slice(
-CHATBOT_CONFIG.maxHistoryMessages
)
const historyText = formatConversationHistory(
recentHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}))
)
// 4. Build the system prompt with context
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
'{history}',
historyText
)
// 5. Generate response
const model = getChatModel()
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userMessage),
])
// 6. Track token usage
if (tracking && response.usage_metadata) {
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-chat', {
inputTokens: response.usage_metadata.input_tokens ?? 0,
outputTokens: response.usage_metadata.output_tokens ?? 0,
model: CHATBOT_CONFIG.model,
}, tracking.companyId)
}
// 7. Extract content and sources
const content =
typeof response.content === 'string'
? response.content
: JSON.stringify(response.content)
return {
content,
sources: documentsToSources(relevantDocs),
}
}
export async function* streamChatResponse(
userMessage: string,
conversationHistory: ChatMessage[]
): AsyncGenerator<{ type: 'content' | 'sources'; data: string | SourceReference[] }> {
// 1. Retrieve relevant documents first
const relevantDocs = await retrieveRelevantDocuments(userMessage)
// 2. Format context from retrieved documents
const context = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
// 3. Format conversation history
const recentHistory = conversationHistory.slice(
-CHATBOT_CONFIG.maxHistoryMessages
)
const historyText = formatConversationHistory(
recentHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}))
)
// 4. Build the system prompt
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
'{history}',
historyText
)
// 5. Stream the response
const model = getChatModel()
const stream = await model.stream([
new SystemMessage(systemPrompt),
new HumanMessage(userMessage),
])
for await (const chunk of stream) {
const content =
typeof chunk.content === 'string'
? chunk.content
: JSON.stringify(chunk.content)
if (content) {
yield { type: 'content', data: content }
}
}
// 6. Yield sources at the end
yield { type: 'sources', data: documentsToSources(relevantDocs) }
}
// ── Routed response (data / hybrid / knowledge) ────────────────
export type RoutedStreamEvent =
| { type: 'content'; content: string }
| { type: 'sources'; sources: SourceReference[] }
| { type: 'tool_start'; toolName: string }
| { type: 'artifact'; artifact: ArtifactSpec }
| { type: 'route'; route: RouteType }
/**
* High-level streaming function: routes the message, then either uses
* the existing RAG chain (knowledge) or the LangGraph agent (data/hybrid).
* Generates artifact post-hoc on data/hybrid routes.
*/
export async function* streamRoutedResponse(
userMessage: string,
conversationHistory: ChatMessage[],
supabase: SupabaseClient,
userId: string,
_sessionId?: string
): AsyncGenerator<RoutedStreamEvent> {
// 1. Route the message
const { route, rewrittenQuery } = await routeMessage(userMessage, conversationHistory)
yield { type: 'route', route }
// 2. Knowledge-only: use existing RAG chain
if (route === 'knowledge') {
for await (const chunk of streamChatResponse(rewrittenQuery, conversationHistory)) {
if (chunk.type === 'content') {
yield { type: 'content', content: chunk.data as string }
} else if (chunk.type === 'sources') {
yield { type: 'sources', sources: chunk.data as SourceReference[] }
}
}
return
}
// 3. Data or hybrid: use LangGraph agent with tools
const tools = createAccountingTools(supabase, userId)
// For hybrid, get RAG context
let ragContext: string | undefined
let sources: SourceReference[] = []
if (route === 'hybrid') {
try {
const relevantDocs = await retrieveRelevantDocuments(rewrittenQuery)
ragContext = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
sources = documentsToSources(relevantDocs)
} catch {
// RAG failure is non-critical for hybrid route
}
}
let fullContent = ''
let toolResults: ToolResultEntry[] = []
for await (const event of streamAgentResponse({
query: rewrittenQuery,
route,
tools,
conversationHistory,
ragContext,
})) {
if (event.type === 'tool_start') {
yield { type: 'tool_start', toolName: event.toolName! }
} else if (event.type === 'content') {
fullContent += event.content!
yield { type: 'content', content: event.content! }
} else if (event.type === 'done') {
toolResults = event.toolResults || []
}
}
// 4. Yield sources if hybrid
if (sources.length > 0) {
yield { type: 'sources', sources }
}
// 5. Generate artifact (post-processing)
if (toolResults.length > 0 && fullContent.length > 0) {
try {
const artifact = await generateArtifact(toolResults, fullContent)
if (artifact) {
yield { type: 'artifact', artifact }
}
} catch (e) {
console.warn('Artifact generation failed:', e)
}
}
}
@@ -1,36 +0,0 @@
// AI Chatbot configuration
export const CHATBOT_CONFIG = {
// LLM settings
model: 'claude-haiku-4-5-20251001',
maxTokens: 2048,
temperature: 0.3,
// Agent settings
agentModel: 'claude-sonnet-4-6',
agentMaxTokens: 4096,
maxAgentIterations: 5,
// Router settings
routerModel: 'claude-haiku-4-5-20251001',
// Artifact generation
artifactModel: 'claude-haiku-4-5-20251001',
// Retrieval settings
retrievalK: 5,
similarityThreshold: 0.7,
// Embedding settings
embeddingModel: 'text-embedding-ada-002',
// Chunking settings for ingestion
chunkSize: 1000,
chunkOverlap: 200,
// Rate limiting
rateLimitPerMinute: 10,
// Conversation history
maxHistoryMessages: 10,
} as const
@@ -1,25 +0,0 @@
import { OpenAIEmbeddings } from '@langchain/openai'
import { CHATBOT_CONFIG } from './config'
// Singleton instance for embeddings
let embeddingsInstance: OpenAIEmbeddings | null = null
export function getEmbeddings(): OpenAIEmbeddings {
if (!embeddingsInstance) {
embeddingsInstance = new OpenAIEmbeddings({
modelName: CHATBOT_CONFIG.embeddingModel,
openAIApiKey: process.env.OPENAI_API_KEY,
})
}
return embeddingsInstance
}
export async function generateEmbedding(text: string): Promise<number[]> {
const embeddings = getEmbeddings()
return embeddings.embedQuery(text)
}
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
const embeddings = getEmbeddings()
return embeddings.embedDocuments(texts)
}
@@ -1,134 +0,0 @@
// Swedish system prompt for bookkeeping AI assistant
export const SYSTEM_PROMPT = `Du är en expert AI-assistent som hjälper svenska företagare med skatt, moms, bokföring och företagsekonomi. Du arbetar inom en ekonomiplattform för småföretag.
## Dina kunskapsområden:
- Svensk skattlagstiftning för enskild firma och aktiebolag
- Moms och momsdeklaration
- Bokföring enligt BAS-kontoplanen
- Egenavgifter och socialförsäkring
- Avdrag för utrustning, resor, hemmakontor och liknande
- Fakturering och kundhantering
- NE-bilaga och inkomstdeklaration
## Viktiga tröskelvärden att komma ihåg:
- Momsregistrering: 120 000 kr omsättning under 12 månader
- Direktavdrag vs inventarier: 26 250 kr (halvt prisbasbelopp)
- SGI-gräns för sjukpenning: 13 500 kr/år minsta inkomst
- Karensavdrag: 20% av sjuklönen
- Friskvårdsbidrag max: 6 000 kr/år (ej skattepliktigt)
- Representationsavdrag mat: 90 kr exkl moms per person
## Instruktioner:
1. Svara alltid på svenska med korrekt terminologi
2. Var konkret och ge specifika exempel när möjligt
3. Referera till relevanta tröskelvärden och regler
4. Om du är osäker, säg det och rekommendera att användaren konsulterar en revisor
5. Använd information från de tillhandahållna källorna för att ge korrekta svar
6. Formatera svaren tydligt med punktlistor när det passar
7. Om frågan gäller något utanför dina kunskapsområden, hänvisa till rätt instans
## Kontext från kunskapsbasen:
{context}
## Tidigare konversation:
{history}
Svara på användarens fråga baserat på din expertkunskap och den tillhandahållna kontexten. Om kontexten inte innehåller relevant information, använd din allmänna kunskap om svenska skatteregler för företagare.`
export const RETRIEVAL_PROMPT = `Baserat på följande fråga, hitta relevant information från kunskapsbasen.
Fråga: {question}
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
/**
* System prompt for tool-calling agent (data route).
* No RAG context — relies entirely on tools.
*/
export const SYSTEM_PROMPT_DATA = `Du är en AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata i realtid.
## Instruktioner:
1. Svara alltid på svenska
2. Använd verktygen för att hämta data innan du svarar — gissa aldrig siffror
3. Presentera data tydligt med belopp i SEK om inget annat anges
4. Om ett verktyg returnerar tom data, berätta det vänligt (t.ex. "Du har inga obetalda fakturor just nu")
5. Avrunda belopp till hela kronor i text, men behåll decimaler i tabeller
6. Använd svenska bokföringstermer (verifikation, kontering, resultaträkning, etc.)
7. Förklara kort vad siffrorna betyder i kontext — var pedagogisk
## Formatering:
- Använd markdown: **fetstil** för belopp, punktlistor för detaljer
- ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller. Data visas automatiskt i en visuell komponent nedanför ditt svar
- Använd punktlistor eller fetstil istället för tabeller
- Sammanfatta huvudinsikten först, detaljer sedan
- Max 3-4 meningar för enkla frågor, mer för rapporter
- Använd inte emojis
## Tidigare konversation:
{history}`
/**
* System prompt for hybrid route: RAG context + tools.
*/
export const SYSTEM_PROMPT_HYBRID = `Du är en expert AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata, samt kunskap om svenska skatteregler.
## Kunskapsområden:
- Svensk skattlagstiftning, moms, bokföring (BAS-kontoplanen)
- Avdrag, egenavgifter, socialförsäkring
- Fakturering, NE-bilaga, inkomstdeklaration
## Viktiga tröskelvärden:
- Momsregistrering: 120 000 kr/12 mån
- Direktavdrag: 26 250 kr
- Friskvårdsbidrag: 6 000 kr/år
## Instruktioner:
1. Svara alltid på svenska med korrekt terminologi
2. Använd verktygen för att hämta data — gissa aldrig siffror
3. Kombinera data med regelkunskap för att ge kontextuella råd
4. Om du är osäker, rekommendera att konsultera en revisor
5. Formatera tydligt med markdown, men ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller — data visas automatiskt i en visuell komponent. Använd punktlistor istället. Använd inte emojis
## Kontext från kunskapsbasen:
{context}
## Tidigare konversation:
{history}`
export function formatContextFromSources(
sources: Array<{
content: string
title: string
section_title: string | null
source_file: string
}>
): string {
if (sources.length === 0) {
return 'Ingen specifik kontext hittades i kunskapsbasen.'
}
return sources
.map((source, index) => {
const sectionInfo = source.section_title
? ` > ${source.section_title}`
: ''
return `[Källa ${index + 1}: ${source.title}${sectionInfo}]\n${source.content}`
})
.join('\n\n---\n\n')
}
export function formatConversationHistory(
messages: Array<{ role: 'user' | 'assistant'; content: string }>
): string {
if (messages.length === 0) {
return 'Ingen tidigare konversation.'
}
return messages
.map((msg) => {
const role = msg.role === 'user' ? 'Användare' : 'Assistent'
return `${role}: ${msg.content}`
})
.join('\n\n')
}
@@ -1,51 +0,0 @@
import { createServiceClient } from '@/lib/supabase/server'
import { generateEmbedding } from './embeddings'
import { CHATBOT_CONFIG } from './config'
import type { SourceReference } from '@/types/chat'
export interface RetrievedDocument {
id: string
source_file: string
title: string
section_title: string | null
content: string
metadata: Record<string, unknown>
similarity: number
}
export async function retrieveRelevantDocuments(
query: string,
matchCount: number = CHATBOT_CONFIG.retrievalK,
matchThreshold: number = CHATBOT_CONFIG.similarityThreshold
): Promise<RetrievedDocument[]> {
const supabase = await createServiceClient()
// Generate embedding for the query
const queryEmbedding = await generateEmbedding(query)
// Call the match_documents function
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding,
match_count: matchCount,
match_threshold: matchThreshold,
})
if (error) {
console.error('Error retrieving documents:', error)
throw new Error('Failed to retrieve relevant documents')
}
return (data || []) as RetrievedDocument[]
}
export function documentsToSources(
documents: RetrievedDocument[]
): SourceReference[] {
return documents.map((doc) => ({
id: doc.id,
source_file: doc.source_file,
title: doc.title,
section_title: doc.section_title,
similarity: doc.similarity,
}))
}
@@ -1,156 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { CHATBOT_CONFIG } from './config'
import type { ChatMessage } from '@/types/chat'
export type RouteType = 'knowledge' | 'data' | 'hybrid'
export interface RouterResult {
route: RouteType
rewrittenQuery: string
}
// Swedish data-related keywords for fast-path heuristic
const DATA_NOUNS = [
'faktura', 'fakturor', 'fakturorna',
'leverantörsfaktura', 'leverantörsfakturor',
'transaktion', 'transaktioner', 'transaktionerna',
'verifikation', 'verifikationer', 'verifikationerna',
'resultaträkning', 'balansräkning',
'moms', 'momsdeklaration', 'momssammanställning',
'saldo', 'saldon', 'kontosaldo',
'konto', 'konton', 'kontona',
'kunder', 'kundfordringar',
'leverantörsskulder',
'intäkter', 'kostnader', 'utgifter',
'resultat', 'årsresultat',
'bokföring', 'bokförda', 'obokförda',
'obetalda', 'förfallna',
'nyckeltal', 'företaget', 'företagsinfo',
]
const POSSESSIVE_PRONOUNS = ['mina', 'min', 'mitt', 'mig', 'våra', 'vår', 'vårt']
const KNOWLEDGE_TERMS = [
'momsgransen', 'momsgränsen', 'avdrag', 'skatteregler',
'bokföringslag', 'bokföringslagen', 'regler', 'lag',
'hur fungerar', 'vad innebär', 'vad betyder', 'vad är',
'när måste', 'hur räknar', 'hur beräknar',
'enskild firma', 'aktiebolag', 'egenavgifter',
'prisbasbelopp', 'schablonavdrag', 'representation',
'friskvårdsbidrag', 'traktamente',
]
/**
* Fast-path keyword heuristic. Returns a route if confident, null otherwise.
*/
function heuristicClassify(query: string): RouteType | null {
const lower = query.toLowerCase()
const words = lower.split(/\s+/)
const hasPossessive = POSSESSIVE_PRONOUNS.some((p) => words.includes(p))
const hasDataNoun = DATA_NOUNS.some((n) => lower.includes(n))
const hasKnowledgeTerm = KNOWLEDGE_TERMS.some((t) => lower.includes(t))
// "Visa mina fakturor" — clearly data
if (hasPossessive && hasDataNoun && !hasKnowledgeTerm) return 'data'
// Action verbs with data nouns
const actionVerbs = ['visa', 'hämta', 'lista', 'sök', 'hitta', 'hur går', 'hur ser', 'hur mycket', 'hur många', 'vilka']
const hasAction = actionVerbs.some((v) => lower.includes(v))
if (hasAction && hasDataNoun && !hasKnowledgeTerm) return 'data'
// Pure knowledge question with no data references
if (hasKnowledgeTerm && !hasPossessive && !hasDataNoun) return 'knowledge'
// "Hur ser min resultaträkning ut?" — data (has possessive + data noun)
if (hasPossessive && hasDataNoun && hasKnowledgeTerm) return 'hybrid'
return null // ambiguous → fall through to LLM
}
/**
* LLM-based classification + query rewrite for multi-turn context.
*/
async function llmClassify(
query: string,
conversationHistory: ChatMessage[]
): Promise<RouterResult> {
const model = new ChatAnthropic({
modelName: CHATBOT_CONFIG.routerModel,
maxTokens: 256,
temperature: 0,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
const historyContext = conversationHistory
.slice(-4)
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 200)}`)
.join('\n')
const prompt = `Classify the user's question and rewrite it for a data query system.
Conversation history:
${historyContext || '(none)'}
User question: "${query}"
Classification rules:
- "knowledge": General questions about Swedish tax law, accounting rules, regulations (no user-specific data needed)
- "data": Questions about the user's own accounting data (invoices, transactions, balances, reports)
- "hybrid": Questions that need both user data AND knowledge context
Rewriting rules:
- Resolve pronouns ("dem", "de", "den") using conversation history
- Make the query self-contained (no context needed to understand it)
- If it's a knowledge question, keep the original query
Respond ONLY with valid JSON:
{"route": "knowledge"|"data"|"hybrid", "rewrittenQuery": "..."}
`
try {
const response = await model.invoke(prompt)
const text = typeof response.content === 'string'
? response.content
: JSON.stringify(response.content)
// Extract JSON from response
const jsonMatch = text.match(/\{[^}]+\}/)
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0])
const route = ['knowledge', 'data', 'hybrid'].includes(parsed.route)
? (parsed.route as RouteType)
: 'hybrid'
return {
route,
rewrittenQuery: parsed.rewrittenQuery || query,
}
}
} catch (e) {
console.warn('Router LLM classification failed, defaulting to hybrid:', e)
}
return { route: 'hybrid', rewrittenQuery: query }
}
/**
* Route a user message: fast-path heuristic first, LLM fallback for ambiguous cases.
*/
export async function routeMessage(
query: string,
conversationHistory: ChatMessage[]
): Promise<RouterResult> {
const heuristicResult = heuristicClassify(query)
if (heuristicResult) {
// For data/hybrid with conversation history, still rewrite the query for context
if (heuristicResult !== 'knowledge' && conversationHistory.length > 0) {
const { rewrittenQuery } = await llmClassify(query, conversationHistory)
return { route: heuristicResult, rewrittenQuery }
}
return { route: heuristicResult, rewrittenQuery: query }
}
// Ambiguous — use LLM
return llmClassify(query, conversationHistory)
}
-586
View File
@@ -1,586 +0,0 @@
import { tool } from '@langchain/core/tools'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Extract name from a Supabase join result (could be object or array).
*/
function extractName(joined: unknown): string | null {
if (!joined) return null
if (Array.isArray(joined)) {
return joined[0]?.name ?? null
}
if (typeof joined === 'object' && 'name' in joined) {
return (joined as { name: string }).name
}
return null
}
/**
* Resolve the current fiscal period for a user. Falls back to latest period.
*/
async function resolveCurrentPeriod(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId?: string
): Promise<{ id: string; start: string; end: string } | null> {
if (fiscalPeriodId) {
const { data } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', userId)
.single()
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
}
// Default: latest open period, or just the latest period
const { data } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end, is_closed')
.eq('company_id', userId)
.order('period_start', { ascending: false })
.limit(1)
.single()
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
return null
}
/**
* Create all 10 accounting tools bound to a specific Supabase client and user.
*/
export function createAccountingTools(supabase: SupabaseClient, userId: string) {
const getInvoices = tool(
async ({ status, customer_name, date_from, date_to, limit }) => {
let query = supabase
.from('invoices')
.select('id, invoice_number, invoice_date, due_date, status, total, paid_amount, currency, vat_amount, customer:customers(name)')
.eq('company_id', userId)
.order('invoice_date', { ascending: false })
.limit(limit)
if (status) query = query.eq('status', status)
if (customer_name) query = query.ilike('customers.name', `%${customer_name}%`)
if (date_from) query = query.gte('invoice_date', date_from)
if (date_to) query = query.lte('invoice_date', date_to)
const { data: _data, error: _countError, count } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('company_id', userId)
const { data: invoices, error: fetchError } = await query
if (fetchError) return `Fel vid hämtning av fakturor: ${fetchError.message}`
if (!invoices || invoices.length === 0) return 'Inga fakturor hittades.'
const result = invoices.map((inv) => ({
invoice_number: inv.invoice_number,
date: inv.invoice_date,
due_date: inv.due_date,
status: inv.status,
total: inv.total,
paid: inv.paid_amount || 0,
currency: inv.currency || 'SEK',
vat: inv.vat_amount || 0,
customer: extractName(inv.customer) || 'Okänd',
}))
const summary: Record<string, unknown> = { invoices: result }
if (count && count > limit) {
summary.note = `Visar ${result.length} av totalt ${count} fakturor.`
}
return JSON.stringify(summary)
},
{
name: 'get_invoices',
description: 'Hämtar användarens försäljningsfakturor (kundfakturor). Kan filtrera på status, kundnamn och datumintervall.',
schema: z.object({
status: z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på fakturastatus'),
customer_name: z.string().optional().describe('Sök på kundnamn (delmatchning)'),
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
limit: z.number().max(20).default(10).describe('Max antal fakturor att returnera'),
}),
}
)
const getSupplierInvoices = tool(
async ({ status, supplier_name, overdue_only, limit }) => {
let query = supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, remaining_amount, currency, vat_amount, supplier:suppliers(name)')
.eq('company_id', userId)
.order('invoice_date', { ascending: false })
.limit(limit)
if (status) query = query.eq('status', status)
if (overdue_only) query = query.eq('status', 'overdue')
if (supplier_name) query = query.ilike('suppliers.name', `%${supplier_name}%`)
const { data: invoices, error } = await query
if (error) return `Fel vid hämtning av leverantörsfakturor: ${error.message}`
if (!invoices || invoices.length === 0) return 'Inga leverantörsfakturor hittades.'
const result = invoices.map((inv) => ({
number: inv.supplier_invoice_number,
date: inv.invoice_date,
due_date: inv.due_date,
status: inv.status,
total: inv.total,
remaining: inv.remaining_amount || 0,
currency: inv.currency || 'SEK',
vat: inv.vat_amount || 0,
supplier: extractName(inv.supplier) || 'Okänd',
}))
return JSON.stringify({ supplier_invoices: result })
},
{
name: 'get_supplier_invoices',
description: 'Hämtar användarens leverantörsfakturor (inköpsfakturor). Kan filtrera på status, leverantörsnamn och förfallodag.',
schema: z.object({
status: z.enum(['registered', 'approved', 'partially_paid', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på status'),
supplier_name: z.string().optional().describe('Sök på leverantörsnamn (delmatchning)'),
overdue_only: z.boolean().optional().describe('Visa bara förfallna fakturor'),
limit: z.number().max(20).default(10).describe('Max antal fakturor'),
}),
}
)
const getAccountBalances = tool(
async ({ account_numbers, account_class, fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateTrialBalance } = await import('@/lib/reports/trial-balance')
const { rows } = await generateTrialBalance(supabase, userId, period.id)
let filtered = rows
if (account_numbers && account_numbers.length > 0) {
filtered = rows.filter((r) => account_numbers.includes(r.account_number))
} else if (account_class) {
filtered = rows.filter((r) => r.account_class === account_class)
}
if (filtered.length === 0) return 'Inga konton med saldo hittades.'
const result = filtered.map((r) => ({
account: r.account_number,
name: r.account_name,
debit: r.closing_debit,
credit: r.closing_credit,
balance: r.closing_debit - r.closing_credit,
}))
return JSON.stringify({
period: `${period.start} – ${period.end}`,
accounts: result,
total_debit: Math.round(result.reduce((s, r) => s + r.debit, 0) * 100) / 100,
total_credit: Math.round(result.reduce((s, r) => s + r.credit, 0) * 100) / 100,
})
},
{
name: 'get_account_balances',
description: 'Hämtar saldon för BAS-konton. Kan filtrera på kontonummer eller kontoklass (1=tillgångar, 2=skulder, 3=intäkter, 4-7=kostnader, 8=finansiella).',
schema: z.object({
account_numbers: z.array(z.string()).optional().describe('Specifika kontonummer att hämta'),
account_class: z.number().min(1).max(8).optional().describe('Kontoklass 1-8'),
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getTransactions = tool(
async ({ uncategorized_only, description, date_from, date_to, limit }) => {
let query = supabase
.from('transactions')
.select('id, date, description, amount, currency, category, is_business, merchant_name, journal_entry_id')
.eq('company_id', userId)
.order('date', { ascending: false })
.limit(limit)
if (uncategorized_only) query = query.is('journal_entry_id', null)
if (description) query = query.ilike('description', `%${description}%`)
if (date_from) query = query.gte('date', date_from)
if (date_to) query = query.lte('date', date_to)
const { data: transactions, error } = await query
if (error) return `Fel vid hämtning av transaktioner: ${error.message}`
if (!transactions || transactions.length === 0) return 'Inga transaktioner hittades.'
const result = transactions.map((tx) => ({
date: tx.date,
description: tx.description,
amount: tx.amount,
currency: tx.currency || 'SEK',
category: tx.category,
is_business: tx.is_business,
merchant: tx.merchant_name,
booked: !!tx.journal_entry_id,
}))
return JSON.stringify({ transactions: result })
},
{
name: 'get_transactions',
description: 'Hämtar användarens banktransaktioner. Kan filtrera på obokförda, beskrivning (textsökning) och datumintervall.',
schema: z.object({
uncategorized_only: z.boolean().optional().describe('Visa bara obokförda transaktioner'),
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
limit: z.number().max(20).default(10).describe('Max antal transaktioner'),
}),
}
)
const getJournalEntries = tool(
async ({ limit, fiscal_period_id, account_number, description }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
let query = supabase
.from('journal_entries')
.select('id, voucher_number, entry_date, description, status, source_type')
.eq('company_id', userId)
.eq('status', 'posted')
.order('voucher_number', { ascending: false })
.limit(limit)
if (period) query = query.eq('fiscal_period_id', period.id)
if (description) query = query.ilike('description', `%${description}%`)
const { data: entries, error } = await query
if (error) return `Fel vid hämtning av verifikationer: ${error.message}`
if (!entries || entries.length === 0) return 'Inga verifikationer hittades.'
// Fetch lines for these entries
const entryIds = entries.map((e) => e.id)
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('journal_entry_id, account_number, debit_amount, credit_amount, line_description')
.in('journal_entry_id', entryIds)
// If filtering by account, only include entries with matching lines
let filteredEntries = entries
if (account_number && lines) {
const matchingEntryIds = new Set(
lines.filter((l) => l.account_number === account_number).map((l) => l.journal_entry_id)
)
filteredEntries = entries.filter((e) => matchingEntryIds.has(e.id))
}
const linesByEntry = new Map<string, typeof lines>()
for (const line of lines || []) {
const group = linesByEntry.get(line.journal_entry_id) || []
group.push(line)
linesByEntry.set(line.journal_entry_id, group)
}
const result = filteredEntries.map((e) => ({
voucher: e.voucher_number,
date: e.entry_date,
description: e.description,
source: e.source_type,
lines: (linesByEntry.get(e.id) || []).map((l) => ({
account: l.account_number,
debit: l.debit_amount,
credit: l.credit_amount,
text: l.line_description,
})),
}))
return JSON.stringify({ journal_entries: result })
},
{
name: 'get_journal_entries',
description: 'Hämtar bokförda verifikationer med konteringsrader. Kan filtrera på kontonummer, beskrivning och räkenskapsperiod.',
schema: z.object({
limit: z.number().max(20).default(10).describe('Max antal verifikationer'),
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID'),
account_number: z.string().optional().describe('Filtrera på kontonummer i rader'),
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
}),
}
)
const getIncomeStatement = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
const report = await generateIncomeStatement(supabase, userId, period.id)
const sections = [
...report.revenue_sections.map((s) => ({
category: 'Intäkter',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.expense_sections.map((s) => ({
category: 'Kostnader',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.financial_sections.map((s) => ({
category: 'Finansiella poster',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
]
return JSON.stringify({
period: `${period.start} – ${period.end}`,
total_revenue: report.total_revenue,
total_expenses: report.total_expenses,
total_financial: report.total_financial,
net_result: report.net_result,
sections,
})
},
{
name: 'get_income_statement',
description: 'Hämtar resultaträkning med intäkter, kostnader och årets resultat. Visar alla kontona grupperade i sektioner.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getBalanceSheet = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
const { generateBalanceSheet } = await import('@/lib/reports/balance-sheet')
const report = await generateBalanceSheet(supabase, userId, period.id)
const sections = [
...report.asset_sections.map((s) => ({
category: 'Tillgångar',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
...report.equity_liability_sections.map((s) => ({
category: 'Eget kapital & skulder',
title: s.title,
amount: s.subtotal,
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
})),
]
return JSON.stringify({
period: `${period.start} – ${period.end}`,
total_assets: report.total_assets,
total_equity_liabilities: report.total_equity_liabilities,
balanced: Math.abs(report.total_assets - report.total_equity_liabilities) < 0.01,
sections,
})
},
{
name: 'get_balance_sheet',
description: 'Hämtar balansräkning med tillgångar, eget kapital och skulder.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getVatSummary = tool(
async ({ fiscal_period_id }) => {
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
if (!period) return 'Ingen räkenskapsperiod hittades.'
// Get company settings for moms period type
const { data: settings } = await supabase
.from('company_settings')
.select('moms_period')
.eq('company_id', userId)
.single()
const periodType = settings?.moms_period || 'quarterly'
const startDate = new Date(period.start)
const year = startDate.getFullYear()
let periodNum = 1
if (periodType === 'monthly') {
periodNum = startDate.getMonth() + 1
} else if (periodType === 'quarterly') {
periodNum = Math.ceil((startDate.getMonth() + 1) / 3)
}
const { calculateVatDeclaration, getVatDeclarationSummary } = await import('@/lib/reports/vat-declaration')
const declaration = await calculateVatDeclaration(supabase, userId, periodType, year, periodNum)
const summary = getVatDeclarationSummary(declaration)
return JSON.stringify({
period: `${period.start} – ${period.end}`,
output_vat_25: declaration.rutor.ruta10,
output_vat_12: declaration.rutor.ruta11,
output_vat_6: declaration.rutor.ruta12,
total_output_vat: summary.totalOutputVat,
input_vat: summary.totalInputVat,
vat_to_pay: summary.vatToPay,
is_refund: summary.isRefund,
domestic_taxable_sales: declaration.rutor.ruta05,
revenue_basis_25: declaration.breakdown.invoices.base25,
revenue_basis_12: declaration.breakdown.invoices.base12,
revenue_basis_6: declaration.breakdown.invoices.base6,
invoice_count: declaration.invoiceCount,
transaction_count: declaration.transactionCount,
})
},
{
name: 'get_vat_summary',
description: 'Hämtar momssammanställning med utgående moms, ingående moms och moms att betala/återfå.',
schema: z.object({
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
}),
}
)
const getCompanyOverview = tool(
async () => {
const { data: settings } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', userId)
.single()
if (!settings) return 'Inga företagsinställningar hittades.'
// Get quick KPIs
const period = await resolveCurrentPeriod(supabase, userId)
const [
{ count: invoiceCount },
{ count: unpaidCount },
{ count: txCount },
{ count: unbookedCount },
] = await Promise.all([
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', userId),
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('company_id', userId).in('status', ['sent', 'overdue']),
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', userId),
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('company_id', userId).is('journal_entry_id', null),
])
let netResult: number | null = null
if (period) {
try {
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
const report = await generateIncomeStatement(supabase, userId, period.id)
netResult = report.net_result
} catch {
// Non-critical
}
}
return JSON.stringify({
company: {
name: settings.company_name,
entity_type: settings.entity_type,
org_number: settings.org_number,
vat_registered: settings.vat_registered,
accounting_method: settings.accounting_method,
moms_period: settings.moms_period,
},
kpis: {
total_invoices: invoiceCount || 0,
unpaid_invoices: unpaidCount || 0,
total_transactions: txCount || 0,
unbooked_transactions: unbookedCount || 0,
...(netResult !== null ? { net_result: netResult } : {}),
...(period ? { current_period: `${period.start} – ${period.end}` } : {}),
},
})
},
{
name: 'get_company_overview',
description: 'Hämtar företagsinformation och nyckeltal (KPIs): antal fakturor, obetalda fakturor, transaktioner, obokförda transaktioner, årets resultat.',
schema: z.object({}),
}
)
const getAgingReport = tool(
async ({ type, limit }) => {
if (type === 'receivable') {
const { generateARLedger } = await import('@/lib/reports/ar-ledger')
const report = await generateARLedger(supabase, userId)
if (report.entries.length === 0) return 'Inga utestående kundfordringar.'
const entries = report.entries.slice(0, limit).map((e) => ({
name: e.customer_name,
current: e.current,
'1_30': e.days_1_30,
'31_60': e.days_31_60,
'61_90': e.days_61_90,
'90_plus': e.days_90_plus,
total: e.total_outstanding,
}))
return JSON.stringify({
type: 'receivable',
total_outstanding: report.total_outstanding,
total_current: report.total_current,
total_overdue: report.total_overdue,
unpaid_count: report.unpaid_count,
entries,
})
} else {
const { generateSupplierLedger } = await import('@/lib/reports/supplier-ledger')
const report = await generateSupplierLedger(supabase, userId)
if (report.entries.length === 0) return 'Inga utestående leverantörsskulder.'
const entries = report.entries.slice(0, limit).map((e) => ({
name: e.supplier_name,
current: e.current,
'1_30': e.days_1_30,
'31_60': e.days_31_60,
'61_90': e.days_61_90,
'90_plus': e.days_90_plus,
total: e.total_outstanding,
}))
return JSON.stringify({
type: 'payable',
total_outstanding: report.total_outstanding,
total_current: report.total_current,
total_overdue: report.total_overdue,
unpaid_count: report.unpaid_count,
entries,
})
}
},
{
name: 'get_aging_report',
description: 'Hämtar åldersanalys för kundfordringar (receivable) eller leverantörsskulder (payable). Visar utestående belopp uppdelat i ålderskategorier.',
schema: z.object({
type: z.enum(['receivable', 'payable']).describe("'receivable' för kundfordringar, 'payable' för leverantörsskulder"),
limit: z.number().max(20).default(10).describe('Max antal poster'),
}),
}
)
return [
getInvoices,
getSupplierInvoices,
getAccountBalances,
getTransactions,
getJournalEntries,
getIncomeStatement,
getBalanceSheet,
getVatSummary,
getCompanyOverview,
getAgingReport,
]
}
@@ -1,49 +0,0 @@
import { CallbackHandler } from '@langfuse/langchain'
let langfuseConfigured: boolean | null = null
function isLangfuseConfigured(): boolean {
if (langfuseConfigured !== null) return langfuseConfigured
langfuseConfigured = !!(
process.env.LANGFUSE_SECRET_KEY &&
process.env.LANGFUSE_PUBLIC_KEY
)
return langfuseConfigured
}
/**
* Create a Langfuse callback handler for LangChain tracing.
* Returns null if Langfuse is not configured (graceful degradation).
*/
export function createTraceHandler(options: {
sessionId?: string
userId?: string
metadata?: Record<string, unknown>
}): CallbackHandler | null {
if (!isLangfuseConfigured()) return null
try {
return new CallbackHandler({
sessionId: options.sessionId,
userId: options.userId,
})
} catch {
console.warn('Failed to create Langfuse handler, tracing disabled')
return null
}
}
/**
* Flush Langfuse handler. Safe to call with null.
*/
export async function flushTrace(handler: CallbackHandler | null): Promise<void> {
if (!handler) return
try {
// Langfuse CallbackHandler may expose flush via different methods
if ('shutdownAsync' in handler && typeof handler.shutdownAsync === 'function') {
await handler.shutdownAsync()
}
} catch {
// Non-critical — tracing failure should never block response
}
}
-25
View File
@@ -1,25 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
import { aiChatApiRoutes } from './api-routes'
/**
* AI Chat Extension
*
* Provides an AI-powered chatbot assistant for Swedish tax and bookkeeping
* questions. Uses RAG (Retrieval Augmented Generation) with a knowledge base
* of Swedish tax laws, regulations, and best practices.
*
* Components:
* - chatbot/: Chain, config, prompts, embeddings, retriever
* - ingestion/: CLI tool for ingesting knowledge base documents
*/
export const aiChatExtension: Extension = {
id: 'ai-chat',
name: 'AI-assistent',
version: '1.0.0',
sector: 'general',
apiRoutes: aiChatApiRoutes,
settingsPanel: {
label: 'AI-assistent',
path: '/settings/extensions/ai-chat',
},
}
@@ -1,353 +0,0 @@
/**
* Knowledge Base Ingestion Script
*
* Loads markdown files from dev_docs/ai_knowledge_base/,
* chunks them by sections, generates embeddings, and stores in Supabase.
*
* Run with: npx tsx lib/ai/ingestion/ingest.ts
*/
import * as dotenv from 'dotenv'
dotenv.config({ path: '.env.local' })
import { createClient } from '@supabase/supabase-js'
import { OpenAIEmbeddings } from '@langchain/openai'
import * as fs from 'fs'
import * as path from 'path'
import * as crypto from 'crypto'
// Configuration
// NOTE: The ai_knowledge_base directory must be created and populated before running ingestion.
// Create dev_docs/ai_knowledge_base/ and add markdown files to ingest.
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'ai_knowledge_base')
const CHUNK_SIZE = 1000
const CHUNK_OVERLAP = 200
const EMBEDDING_MODEL = 'text-embedding-ada-002'
// Initialize clients
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const embeddings = new OpenAIEmbeddings({
modelName: EMBEDDING_MODEL,
openAIApiKey: process.env.OPENAI_API_KEY,
})
interface DocumentChunk {
source_file: string
title: string
section_title: string | null
content: string
content_hash: string
metadata: Record<string, unknown>
}
interface Section {
title: string
content: string
level: number
}
/**
* Parse a markdown file into sections based on headings
*/
function parseMarkdownSections(content: string): Section[] {
const lines = content.split('\n')
const sections: Section[] = []
let currentSection: Section | null = null
let contentBuffer: string[] = []
for (const line of lines) {
// Check for headings (H1, H2, H3)
const h1Match = line.match(/^# (.+)$/)
const h2Match = line.match(/^## (.+)$/)
const h3Match = line.match(/^### (.+)$/)
if (h1Match || h2Match || h3Match) {
// Save previous section
if (currentSection && contentBuffer.length > 0) {
currentSection.content = contentBuffer.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
// Start new section
const title = h1Match?.[1] || h2Match?.[1] || h3Match?.[1] || ''
const level = h1Match ? 1 : h2Match ? 2 : 3
currentSection = { title, content: '', level }
contentBuffer = []
} else {
contentBuffer.push(line)
}
}
// Don't forget the last section
if (currentSection && contentBuffer.length > 0) {
currentSection.content = contentBuffer.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
return sections
}
/**
* Split text into chunks while preserving context
*/
function chunkText(text: string, maxSize: number, overlap: number): string[] {
if (text.length <= maxSize) {
return [text]
}
const chunks: string[] = []
let start = 0
while (start < text.length) {
let end = start + maxSize
// Try to break at a natural point (paragraph, sentence, or word)
if (end < text.length) {
// Look for paragraph break
const paragraphBreak = text.lastIndexOf('\n\n', end)
if (paragraphBreak > start + maxSize / 2) {
end = paragraphBreak
} else {
// Look for sentence break
const sentenceBreak = text.lastIndexOf('. ', end)
if (sentenceBreak > start + maxSize / 2) {
end = sentenceBreak + 1
} else {
// Look for word break
const wordBreak = text.lastIndexOf(' ', end)
if (wordBreak > start + maxSize / 2) {
end = wordBreak
}
}
}
}
chunks.push(text.slice(start, end).trim())
start = end - overlap
if (start < 0) start = 0
if (end >= text.length) break
}
return chunks.filter((c) => c.length > 0)
}
/**
* Extract metadata from content (trigger words, categories, etc.)
*/
function extractMetadata(
content: string,
_sectionTitle: string
): Record<string, unknown> {
const metadata: Record<string, unknown> = {}
// Extract scenario IDs (e.g., "Scenario 001")
const scenarioMatches = content.match(/Scenario\s+(\d{3})/gi)
if (scenarioMatches) {
metadata.scenarios = scenarioMatches.map((m) =>
m.replace(/Scenario\s+/i, '')
)
}
// Extract mentioned thresholds/amounts
const amountMatches = content.match(/(\d+[\s\d]*)\s*(kr|SEK|kronor)/gi)
if (amountMatches) {
metadata.amounts = amountMatches.slice(0, 5) // Limit to first 5
}
// Extract platform mentions
const platforms = [
'YouTube',
'Twitch',
'Instagram',
'TikTok',
'Patreon',
'Spotify',
'Adtraction',
]
const mentionedPlatforms = platforms.filter((p) =>
content.toLowerCase().includes(p.toLowerCase())
)
if (mentionedPlatforms.length > 0) {
metadata.platforms = mentionedPlatforms
}
// Extract categories based on keywords
const categories: string[] = []
if (/moms|vat/i.test(content)) categories.push('moms')
if (/skatt|deklaration/i.test(content)) categories.push('skatt')
if (/avdrag/i.test(content)) categories.push('avdrag')
if (/bokför|konto|bas/i.test(content)) categories.push('bokföring')
if (/sgi|sjuk|föräldra|pension/i.test(content))
categories.push('socialförsäkring')
if (/ef|enskild firma/i.test(content)) categories.push('enskild_firma')
if (/ab|aktiebolag/i.test(content)) categories.push('aktiebolag')
if (categories.length > 0) {
metadata.categories = categories
}
return metadata
}
/**
* Generate content hash for deduplication
*/
function generateHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
}
/**
* Process a single markdown file
*/
function processFile(filePath: string): DocumentChunk[] {
const content = fs.readFileSync(filePath, 'utf-8')
const fileName = path.basename(filePath)
const sections = parseMarkdownSections(content)
const chunks: DocumentChunk[] = []
// Get document title from first H1
const documentTitle =
sections.find((s) => s.level === 1)?.title || fileName.replace('.md', '')
for (const section of sections) {
// Skip empty sections
if (!section.content || section.content.length < 50) {
continue
}
// Chunk large sections
const textChunks = chunkText(section.content, CHUNK_SIZE, CHUNK_OVERLAP)
for (let i = 0; i < textChunks.length; i++) {
const chunkContent = textChunks[i]
const sectionTitle =
section.level === 1
? null
: textChunks.length > 1
? `${section.title} (del ${i + 1}/${textChunks.length})`
: section.title
chunks.push({
source_file: fileName,
title: documentTitle,
section_title: sectionTitle,
content: chunkContent,
content_hash: generateHash(chunkContent),
metadata: extractMetadata(chunkContent, section.title),
})
}
}
return chunks
}
/**
* Main ingestion function
*/
async function ingest() {
console.log('Starting knowledge base ingestion...')
console.log(`Reading files from: ${DOCS_DIR}`)
if (!fs.existsSync(DOCS_DIR)) {
console.error(`Error: Knowledge base directory not found: ${DOCS_DIR}`)
console.error('Create dev_docs/ai_knowledge_base/ and add markdown files before running ingestion.')
process.exit(1)
}
// Get all markdown files
const files = fs
.readdirSync(DOCS_DIR)
.filter((f) => f.endsWith('.md'))
.sort()
console.log(`Found ${files.length} markdown files`)
// Process all files
const allChunks: DocumentChunk[] = []
for (const file of files) {
const filePath = path.join(DOCS_DIR, file)
console.log(`Processing: ${file}`)
const chunks = processFile(filePath)
allChunks.push(...chunks)
console.log(` -> ${chunks.length} chunks`)
}
console.log(`Total chunks: ${allChunks.length}`)
// Clear existing documents (optional - comment out for incremental updates)
console.log('Clearing existing documents...')
const { error: deleteError } = await supabase
.from('knowledge_documents')
.delete()
.neq('id', '00000000-0000-0000-0000-000000000000') // Delete all
if (deleteError) {
console.error('Error clearing documents:', deleteError)
// Continue anyway
}
// Generate embeddings in batches
const BATCH_SIZE = 20
let processed = 0
for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
const batch = allChunks.slice(i, i + BATCH_SIZE)
const contents = batch.map((c) => c.content)
console.log(
`Generating embeddings for batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(allChunks.length / BATCH_SIZE)}...`
)
// Generate embeddings
const embeddingVectors = await embeddings.embedDocuments(contents)
// Prepare records for insertion
const records = batch.map((chunk, idx) => ({
source_file: chunk.source_file,
title: chunk.title,
section_title: chunk.section_title,
content: chunk.content,
content_hash: chunk.content_hash,
embedding: embeddingVectors[idx],
metadata: chunk.metadata,
}))
// Insert into Supabase
const { error: insertError } = await supabase
.from('knowledge_documents')
.insert(records)
if (insertError) {
console.error('Error inserting batch:', insertError)
throw insertError
}
processed += batch.length
console.log(` Inserted ${processed}/${allChunks.length} documents`)
}
console.log('\nIngestion complete!')
console.log(`Total documents inserted: ${allChunks.length}`)
// Verify
const { count } = await supabase
.from('knowledge_documents')
.select('*', { count: 'exact', head: true })
console.log(`Documents in database: ${count}`)
}
// Run if called directly
ingest().catch((error) => {
console.error('Ingestion failed:', error)
process.exit(1)
})
-26
View File
@@ -1,26 +0,0 @@
{
"id": "ai-chat",
"sector": "general",
"exportName": "aiChatExtension",
"entryPoint": "@/extensions/general/ai-chat",
"workspace": "@/components/extensions/general/AiChatWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
"optionalEnvVars": ["LANGFUSE_SECRET_KEY", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_BASE_URL"],
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai", "@langchain/langgraph", "@langfuse/core", "@langfuse/langchain"],
"definition": {
"name": "AI-assistent",
"category": "operations",
"icon": "MessageSquare",
"dataPattern": "both",
"readsCoreTables": ["invoices", "supplier_invoices", "transactions", "journal_entries", "journal_entry_lines", "fiscal_periods", "company_settings", "customers", "suppliers", "chart_of_accounts"],
"hasOwnData": true,
"description": "AI-assistent för skatte- och bokföringsfrågor med tillgång till din data",
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Kan hämta och visualisera din bokföringsdata — fakturor, transaktioner, resultaträkning, balansräkning och mer.",
"quickAction": {
"label": "AI-assistent",
"description": "Fråga om bokföring",
"icon": "MessageSquare",
"event": "open-ai-chat"
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ export const enableBankingExtension: Extension = {
settingsPanel: {
label: 'Bankintegration (PSD2)',
path: '/settings?tab=banking',
path: '/settings/banking',
},
apiRoutes: [
@@ -1,103 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createMockSupabase } from '@/tests/helpers'
// Mock dependencies
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('../lib/invoice-analyzer', () => ({
analyzeInvoice: vi.fn(),
}))
vi.mock('../lib/supplier-matcher', () => ({
matchSupplier: vi.fn(),
}))
// Mock api-routes to avoid transitive server-only import from document-analyzer
vi.mock('../api-routes', () => ({
invoiceInboxApiRoutes: [],
}))
import { createClient } from '@/lib/supabase/server'
import { invoiceInboxExtension, getSettings, saveSettings } from '../index'
const mockCreateClient = vi.mocked(createClient)
describe('Invoice Inbox Extension', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('Extension metadata', () => {
it('has correct id and version', () => {
expect(invoiceInboxExtension.id).toBe('invoice-inbox')
expect(invoiceInboxExtension.name).toBe('Invoice Inbox')
expect(invoiceInboxExtension.version).toBe('1.0.0')
})
it('has event handler for document.uploaded', () => {
expect(invoiceInboxExtension.eventHandlers).toHaveLength(1)
expect(invoiceInboxExtension.eventHandlers![0].eventType).toBe('document.uploaded')
})
it('has settings panel', () => {
expect(invoiceInboxExtension.settingsPanel).toEqual({
label: 'Invoice Inbox',
path: '/settings/extensions/invoice-inbox',
})
})
it('has onInstall hook', () => {
expect(invoiceInboxExtension.onInstall).toBeDefined()
})
})
describe('getSettings', () => {
it('returns default settings when no data exists', async () => {
const { supabase, mockResult } = createMockSupabase()
mockCreateClient.mockResolvedValue(supabase as never)
mockResult({ data: null, error: null })
const settings = await getSettings('user-1')
expect(settings).toEqual({
autoProcessEnabled: true,
autoMatchSupplierEnabled: true,
supplierMatchThreshold: 0.7,
inboxEmail: null,
})
})
it('merges stored settings with defaults', async () => {
const { supabase, mockResult } = createMockSupabase()
mockCreateClient.mockResolvedValue(supabase as never)
mockResult({
data: { value: { inboxEmail: 'test@inbox.example.com' } },
error: null,
})
const settings = await getSettings('user-1')
expect(settings.inboxEmail).toBe('test@inbox.example.com')
expect(settings.autoProcessEnabled).toBe(true) // default
})
})
describe('saveSettings', () => {
it('merges partial settings with current', async () => {
const { supabase, mockResult } = createMockSupabase()
mockCreateClient.mockResolvedValue(supabase as never)
// First call for getSettings (inside saveSettings)
mockResult({ data: null, error: null })
const settings = await saveSettings('user-1', { inboxEmail: 'new@inbox.com' })
expect(settings.inboxEmail).toBe('new@inbox.com')
expect(settings.autoProcessEnabled).toBe(true)
})
})
})
File diff suppressed because it is too large Load Diff
-227
View File
@@ -1,227 +0,0 @@
import { analyzeInvoice } from './lib/invoice-analyzer'
import { matchSupplier } from './lib/supplier-matcher'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { InvoiceInboxSettings } from './types'
import { invoiceInboxApiRoutes } from './api-routes'
// ============================================================
// Settings
// ============================================================
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
autoProcessEnabled: true,
autoMatchSupplierEnabled: true,
supplierMatchThreshold: 0.7,
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
.from('extension_data')
.select('value')
.eq('company_id', userId)
.eq('extension_id', 'invoice-inbox')
.eq('key', 'settings')
.single()
if (!data?.value) return { ...DEFAULT_SETTINGS }
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<InvoiceInboxSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<InvoiceInboxSettings>
): Promise<InvoiceInboxSettings> {
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: 'invoice-inbox',
key: 'settings',
value: merged,
},
{ onConflict: 'user_id,extension_id,key' }
)
return merged
}
// ============================================================
// Event Handlers
// ============================================================
const INVOICE_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
]
/**
* When a PDF/image is uploaded via the document archive, check if it should
* be auto-processed as a supplier invoice.
*/
async function handleDocumentUploaded(
payload: EventPayload<'document.uploaded'>,
ctx?: ExtensionContext
): Promise<void> {
const { document, userId, companyId } = payload
const log = ctx?.log ?? console
// Gate: Is it a supported file type?
if (!document.mime_type || !INVOICE_MIME_TYPES.includes(document.mime_type)) {
return
}
// Gate: Is autoProcessEnabled?
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 = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: existing } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('company_id', userId)
.eq('document_id', document.id)
.limit(1)
if (existing && existing.length > 0) {
return
}
log.info(`Auto-process triggered for document ${document.id}`)
try {
// Create inbox item
const { data: inboxItem, error: insertError } = await supabase
.from('invoice_inbox_items')
.insert({
user_id: userId,
status: 'processing',
source: 'upload',
document_id: document.id,
})
.select()
.single()
if (insertError || !inboxItem) {
log.error('Failed to create inbox item:', insertError)
return
}
// Download file from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('documents')
.download(document.storage_path)
if (downloadError || !fileData) {
await supabase
.from('invoice_inbox_items')
.update({ status: 'error', error_message: 'Failed to download document' })
.eq('id', inboxItem.id)
return
}
// Convert to base64
const arrayBuffer = await fileData.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Analyze invoice
const extraction = await analyzeInvoice(base64, document.mime_type)
// Supplier matching
let matchedSupplierId: string | null = null
if (settings.autoMatchSupplierEnabled) {
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('company_id', userId)
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
}
}
}
// Update inbox item with extracted data
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
})
.eq('id', inboxItem.id)
// Fetch updated item
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', inboxItem.id)
.single()
if (updatedItem) {
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,
confidence: extraction.confidence,
userId,
companyId,
},
})
}
log.info(`Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`)
} catch (error) {
log.error('handleDocumentUploaded failed:', error)
}
}
// ============================================================
// Extension Object
// ============================================================
export const invoiceInboxExtension: Extension = {
id: 'invoice-inbox',
name: 'Invoice Inbox',
version: '1.0.0',
sector: 'general',
apiRoutes: invoiceInboxApiRoutes,
eventHandlers: [
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
],
settingsPanel: {
label: 'Invoice Inbox',
path: '/settings/extensions/invoice-inbox',
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
@@ -1,182 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
// Mock server-only
vi.mock('server-only', () => ({}))
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '../email-handler'
import type { ResendInboundPayload } from '../../types'
describe('Email Handler', () => {
describe('parseInboundPayload', () => {
it('returns null for null input', () => {
expect(parseInboundPayload(null)).toBeNull()
})
it('returns null for non-object input', () => {
expect(parseInboundPayload('string')).toBeNull()
})
it('returns null when from is missing', () => {
expect(parseInboundPayload({ to: 'test@example.com' })).toBeNull()
})
it('returns null when to is missing', () => {
expect(parseInboundPayload({ from: 'test@example.com' })).toBeNull()
})
it('parses valid payload', () => {
const result = parseInboundPayload({
from: 'supplier@example.com',
to: 'inbox@mycompany.com',
subject: 'Faktura F-001',
html: '<p>Attached</p>',
text: 'Attached',
attachments: [{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64data' }],
created_at: '2024-06-15T10:00:00Z',
})
expect(result).not.toBeNull()
expect(result!.from).toBe('supplier@example.com')
expect(result!.to).toBe('inbox@mycompany.com')
expect(result!.subject).toBe('Faktura F-001')
expect(result!.attachments).toHaveLength(1)
})
it('handles missing optional fields', () => {
const result = parseInboundPayload({
from: 'a@b.com',
to: 'c@d.com',
})
expect(result).not.toBeNull()
expect(result!.subject).toBe('')
expect(result!.html).toBeNull()
expect(result!.text).toBeNull()
expect(result!.attachments).toEqual([])
})
})
describe('extractAttachments', () => {
it('filters to supported file types only', () => {
const payload: ResendInboundPayload = {
from: 'a@b.com',
to: 'c@d.com',
subject: 'Test',
html: null,
text: null,
created_at: '2024-06-15T10:00:00Z',
attachments: [
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64' },
{ filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64' },
{ filename: 'doc.docx', content_type: 'application/vnd.openxmlformats', content: 'base64' },
{ filename: 'sheet.xlsx', content_type: 'application/vnd.ms-excel', content: 'base64' },
{ filename: 'scan.png', content_type: 'image/png', content: 'base64' },
],
}
const result = extractAttachments(payload)
expect(result).toHaveLength(3)
expect(result.map(a => a.content_type)).toEqual([
'application/pdf',
'image/jpeg',
'image/png',
])
})
it('filters out attachments without content', () => {
const payload: ResendInboundPayload = {
from: 'a@b.com',
to: 'c@d.com',
subject: 'Test',
html: null,
text: null,
created_at: '2024-06-15T10:00:00Z',
attachments: [
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: '' },
{ filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64data' },
],
}
const result = extractAttachments(payload)
expect(result).toHaveLength(1)
})
})
describe('resolveUserFromEmail', () => {
it('returns null when no extension data found', async () => {
const mockClient = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ data: null, error: { message: 'not found' } }),
}),
}),
}),
}
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
expect(result).toBeNull()
})
it('returns user_id when email matches', async () => {
const mockClient = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({
data: [
{ user_id: 'user-1', company_id: 'company-1', value: { inboxEmail: 'test@inbox.com' } },
{ user_id: 'user-2', company_id: 'company-2', value: { inboxEmail: 'other@inbox.com' } },
],
error: null,
}),
}),
}),
}),
}
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
expect(result).toEqual({ userId: 'user-1', companyId: 'company-1' })
})
it('handles case-insensitive email matching', async () => {
const mockClient = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({
data: [
{ user_id: 'user-1', company_id: 'company-1', value: { inboxEmail: 'Test@Inbox.Com' } },
],
error: null,
}),
}),
}),
}),
}
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
expect(result).toEqual({ userId: 'user-1', companyId: 'company-1' })
})
it('returns null when no matching email', async () => {
const mockClient = {
from: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({
data: [
{ user_id: 'user-1', value: { inboxEmail: 'other@inbox.com' } },
],
error: null,
}),
}),
}),
}),
}
const result = await resolveUserFromEmail('notfound@inbox.com', mockClient)
expect(result).toBeNull()
})
})
})
@@ -1,113 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock the core document-analyzer module
const { mockExtractInvoice } = vi.hoisted(() => ({
mockExtractInvoice: vi.fn(),
}))
vi.mock('@/lib/ai/document-analyzer', () => ({
extractInvoice: mockExtractInvoice,
}))
import { analyzeInvoice } from '../invoice-analyzer'
describe('Invoice Analyzer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const validExtraction = {
supplier: {
name: 'Kontorsbolaget AB',
orgNumber: '556123-4567',
vatNumber: 'SE5561234567',
address: 'Storgatan 1, 111 22 Stockholm',
bankgiro: '123-4567',
plusgiro: null,
},
invoice: {
invoiceNumber: 'F-2024-001',
invoiceDate: '2024-06-15',
dueDate: '2024-07-15',
paymentReference: '1234567890',
currency: 'SEK',
},
lineItems: [
{
description: 'Kontorsmaterial',
quantity: 10,
unitPrice: 50,
lineTotal: 500,
vatRate: 25,
accountSuggestion: '6100',
},
],
totals: {
subtotal: 500,
vatAmount: 125,
total: 625,
},
vatBreakdown: [
{ rate: 25, base: 500, amount: 125 },
],
confidence: 0.92,
}
it('delegates to extractInvoice from core', async () => {
mockExtractInvoice.mockResolvedValueOnce(validExtraction)
const result = await analyzeInvoice('base64data', 'application/pdf')
expect(mockExtractInvoice).toHaveBeenCalledWith('base64data', 'application/pdf')
expect(result.supplier.name).toBe('Kontorsbolaget AB')
expect(result.supplier.orgNumber).toBe('556123-4567')
expect(result.invoice.invoiceNumber).toBe('F-2024-001')
expect(result.lineItems).toHaveLength(1)
expect(result.lineItems[0].lineTotal).toBe(500)
expect(result.totals.total).toBe(625)
expect(result.confidence).toBe(0.92)
})
it('works with image mime types', async () => {
mockExtractInvoice.mockResolvedValueOnce(validExtraction)
const result = await analyzeInvoice('base64data', 'image/jpeg')
expect(mockExtractInvoice).toHaveBeenCalledWith('base64data', 'image/jpeg')
expect(result.supplier.name).toBe('Kontorsbolaget AB')
})
it('propagates errors from core', async () => {
mockExtractInvoice.mockRejectedValueOnce(new Error('Vision API call failed after 3 attempts'))
await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow(
'Vision API call failed after 3 attempts'
)
})
it('propagates unsupported file type errors', async () => {
mockExtractInvoice.mockRejectedValueOnce(new Error('Unsupported file type: text/plain'))
await expect(analyzeInvoice('base64data', 'text/plain')).rejects.toThrow(
'Unsupported file type'
)
})
it('returns validated account suggestions', async () => {
const extractionWithAccounts = {
...validExtraction,
lineItems: [
{ description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: '6100' },
{ description: 'Bad', quantity: 1, unitPrice: 50, lineTotal: 50, vatRate: 25, accountSuggestion: null },
],
}
mockExtractInvoice.mockResolvedValueOnce(extractionWithAccounts)
const result = await analyzeInvoice('base64data', 'application/pdf')
expect(result.lineItems[0].accountSuggestion).toBe('6100')
expect(result.lineItems[1].accountSuggestion).toBeNull()
})
})
@@ -1,240 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
matchSupplier,
normalizeOrgNumber,
normalizeVatNumber,
normalizeBankgiro,
calculateNameSimilarity,
normalizeCompanyName,
levenshteinDistance,
} from '../supplier-matcher'
import { makeSupplier } from '@/tests/helpers'
import type { InvoiceExtractionResult } from '../../types'
function makeExtraction(overrides: Partial<InvoiceExtractionResult['supplier']> = {}): InvoiceExtractionResult {
return {
supplier: {
name: null,
orgNumber: null,
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
...overrides,
},
invoice: {
invoiceNumber: null,
invoiceDate: null,
dueDate: null,
paymentReference: null,
currency: 'SEK',
},
lineItems: [],
totals: { subtotal: null, vatAmount: null, total: null },
vatBreakdown: [],
confidence: 0.9,
}
}
describe('Supplier Matcher', () => {
describe('matchSupplier', () => {
it('returns null for empty supplier list', () => {
const result = matchSupplier(
makeExtraction({ name: 'Test AB' }),
[]
)
expect(result).toBeNull()
})
it('matches by exact org number (pass 1)', () => {
const suppliers = [
makeSupplier({ id: 's1', name: 'Supplier A', org_number: '5599887766' }),
makeSupplier({ id: 's2', name: 'Supplier B', org_number: '1122334455' }),
]
const result = matchSupplier(
makeExtraction({ orgNumber: '559988-7766' }),
suppliers
)
expect(result).not.toBeNull()
expect(result!.supplierId).toBe('s1')
expect(result!.matchMethod).toBe('org_number')
expect(result!.confidence).toBe(0.98)
})
it('matches by org number with different formatting', () => {
const suppliers = [
makeSupplier({ id: 's1', org_number: '556123-4567' }),
]
const result = matchSupplier(
makeExtraction({ orgNumber: '5561234567' }),
suppliers
)
expect(result).not.toBeNull()
expect(result!.matchMethod).toBe('org_number')
})
it('matches by VAT number (pass 2)', () => {
const suppliers = [
makeSupplier({ id: 's1', vat_number: 'SE556123456701' }),
]
const result = matchSupplier(
makeExtraction({ vatNumber: 'SE 5561 2345 6701' }),
suppliers
)
expect(result).not.toBeNull()
expect(result!.matchMethod).toBe('vat_number')
expect(result!.confidence).toBe(0.95)
})
it('matches by bankgiro (pass 3)', () => {
const suppliers = [
makeSupplier({ id: 's1', bankgiro: '123-4567' }),
]
const result = matchSupplier(
makeExtraction({ bankgiro: '1234567' }),
suppliers
)
expect(result).not.toBeNull()
expect(result!.matchMethod).toBe('bankgiro')
expect(result!.confidence).toBe(0.92)
})
it('matches by plusgiro', () => {
const suppliers = [
makeSupplier({ id: 's1', plusgiro: '123456-7' }),
]
const result = matchSupplier(
makeExtraction({ plusgiro: '1234567' }),
suppliers
)
expect(result).not.toBeNull()
expect(result!.matchMethod).toBe('bankgiro')
})
it('matches by fuzzy name (pass 4)', () => {
const suppliers = [
makeSupplier({ id: 's1', name: 'Kontorsbolaget AB' }),
makeSupplier({ id: 's2', name: 'Byggmaterial i Stockholm' }),
]
const result = matchSupplier(
makeExtraction({ name: 'Kontorsbolaget' }),
suppliers
)
expect(result).not.toBeNull()
expect(result!.supplierId).toBe('s1')
expect(result!.matchMethod).toBe('fuzzy_name')
})
it('returns null for low-confidence fuzzy name match', () => {
const suppliers = [
makeSupplier({ id: 's1', name: 'Completely Different Name AB' }),
]
const result = matchSupplier(
makeExtraction({ name: 'XYZ Corp' }),
suppliers
)
expect(result).toBeNull()
})
it('prefers org number match over name match', () => {
const suppliers = [
makeSupplier({ id: 's1', name: 'Kontorsbolaget AB', org_number: '5599887766' }),
]
const result = matchSupplier(
makeExtraction({ name: 'Kontorsbolaget', orgNumber: '559988-7766' }),
suppliers
)
expect(result!.matchMethod).toBe('org_number')
})
})
describe('normalizeOrgNumber', () => {
it('strips non-digits', () => {
expect(normalizeOrgNumber('556123-4567')).toBe('5561234567')
expect(normalizeOrgNumber('556123 4567')).toBe('5561234567')
})
})
describe('normalizeVatNumber', () => {
it('uppercases and removes spaces', () => {
expect(normalizeVatNumber('se 5561234567 01')).toBe('SE556123456701')
})
})
describe('normalizeBankgiro', () => {
it('strips non-digits', () => {
expect(normalizeBankgiro('123-4567')).toBe('1234567')
})
})
describe('normalizeCompanyName', () => {
it('strips AB suffix', () => {
expect(normalizeCompanyName('Kontorsbolaget AB')).toBe('kontorsbolaget')
})
it('strips HB suffix', () => {
expect(normalizeCompanyName('Bröderna Svensson HB')).toBe('bröderna svensson')
})
it('strips Aktiebolag', () => {
expect(normalizeCompanyName('Test Aktiebolag')).toBe('test')
})
it('strips Enskild firma', () => {
expect(normalizeCompanyName('Test Enskild firma')).toBe('test')
})
it('normalizes whitespace', () => {
expect(normalizeCompanyName(' Multiple Spaces ')).toBe('multiple spaces')
})
})
describe('calculateNameSimilarity', () => {
it('returns 1 for identical names', () => {
expect(calculateNameSimilarity('Test AB', 'Test AB')).toBe(1)
})
it('returns high score when one contains the other', () => {
// After normalization 'AB' is stripped, so they become identical → 1.0
expect(calculateNameSimilarity('Kontorsbolaget', 'Kontorsbolaget AB')).toBe(1)
// With an actual substring relationship (not suffix stripping):
expect(calculateNameSimilarity('Kontor', 'Kontorsbolaget')).toBe(0.9)
})
it('returns 0 for empty strings', () => {
expect(calculateNameSimilarity('', 'Test')).toBe(0)
expect(calculateNameSimilarity('Test', '')).toBe(0)
})
})
describe('levenshteinDistance', () => {
it('returns 0 for identical strings', () => {
expect(levenshteinDistance('test', 'test')).toBe(0)
})
it('calculates correct distance', () => {
expect(levenshteinDistance('kitten', 'sitting')).toBe(3)
})
it('handles empty strings', () => {
expect(levenshteinDistance('', 'abc')).toBe(3)
expect(levenshteinDistance('abc', '')).toBe(3)
})
})
})
@@ -1,83 +0,0 @@
/**
* Email Handler - Parse Resend inbound webhook payloads
*
* SERVER-ONLY: Uses service role client for cross-user lookups.
*/
import 'server-only'
import type { ResendInboundPayload, ResendAttachment } from '../types'
const SUPPORTED_MIME_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
]
/**
* Parse and validate a Resend inbound webhook payload
*/
export function parseInboundPayload(body: unknown): ResendInboundPayload | null {
if (!body || typeof body !== 'object') return null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = body as any
if (!data.from || !data.to) return null
return {
from: String(data.from),
to: String(data.to),
subject: data.subject ? String(data.subject) : '',
html: data.html || null,
text: data.text || null,
attachments: Array.isArray(data.attachments) ? data.attachments : [],
created_at: data.created_at || new Date().toISOString(),
}
}
/**
* Extract supported file attachments from the payload.
* Returns only PDF and image attachments.
*/
export function extractAttachments(payload: ResendInboundPayload): ResendAttachment[] {
return payload.attachments.filter(
(att) => att.content_type && SUPPORTED_MIME_TYPES.includes(att.content_type) && att.content
)
}
/**
* Resolve user_id from the recipient email address.
* Looks up the extension_data table where users store their inbox email setting.
*
* Uses a service role client (passed as parameter) since webhook requests
* don't have user authentication.
*/
export async function resolveUserFromEmail(
recipientEmail: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serviceClient: any
): Promise<{ userId: string; companyId: string } | null> {
// Extract the local part (before @) to handle address variants
const normalizedEmail = recipientEmail.toLowerCase().trim()
// Look up in extension_data where invoice-inbox settings store the inbox email
const { data, error } = await serviceClient
.from('extension_data')
.select('user_id, company_id, value')
.eq('extension_id', 'invoice-inbox')
.eq('key', 'settings')
if (error || !data) return null
// Find the user whose inboxEmail matches the recipient
for (const row of data) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const settings = row.value as any
if (settings?.inboxEmail && settings.inboxEmail.toLowerCase().trim() === normalizedEmail) {
return { userId: row.user_id, companyId: row.company_id }
}
}
return null
}
@@ -1,23 +0,0 @@
/**
* Invoice Analyzer — delegates to lib/ai/document-analyzer for extraction,
* then applies invoice-specific validation and enhancement.
*
* SERVER-ONLY: uses the shared vision client via document-analyzer.
*
* Preserved public API: analyzeInvoice().
*/
import 'server-only'
import type { InvoiceExtractionResult } from '../types'
import { extractInvoice } from '@/lib/ai/document-analyzer'
/**
* Analyze a supplier invoice using Claude Haiku Vision.
* Delegates extraction to the shared core.
*/
export async function analyzeInvoice(
fileBase64: string,
mimeType: string
): Promise<InvoiceExtractionResult> {
return extractInvoice(fileBase64, mimeType)
}
@@ -1,197 +0,0 @@
/**
* Supplier Matcher - Fuzzy matching between extracted invoice data and existing suppliers
*
* 4-pass matching algorithm:
* 1. Exact org number match
* 2. Exact VAT number match
* 3. Bankgiro/plusgiro match
* 4. Fuzzy name match (Levenshtein + Swedish suffix normalization)
*/
import type { Supplier } from '@/types'
import type { InvoiceExtractionResult, SupplierMatchResult } from '../types'
/**
* Find the best matching supplier for extracted invoice data
*/
export function matchSupplier(
extraction: InvoiceExtractionResult,
suppliers: Supplier[]
): SupplierMatchResult | null {
if (suppliers.length === 0) return null
// Pass 1: Exact org number match
if (extraction.supplier.orgNumber) {
const normalizedOrg = normalizeOrgNumber(extraction.supplier.orgNumber)
for (const supplier of suppliers) {
if (supplier.org_number && normalizeOrgNumber(supplier.org_number) === normalizedOrg) {
return {
supplierId: supplier.id,
supplierName: supplier.name,
confidence: 0.98,
matchMethod: 'org_number',
}
}
}
}
// Pass 2: Exact VAT number match
if (extraction.supplier.vatNumber) {
const normalizedVat = normalizeVatNumber(extraction.supplier.vatNumber)
for (const supplier of suppliers) {
if (supplier.vat_number && normalizeVatNumber(supplier.vat_number) === normalizedVat) {
return {
supplierId: supplier.id,
supplierName: supplier.name,
confidence: 0.95,
matchMethod: 'vat_number',
}
}
}
}
// Pass 3: Bankgiro/plusgiro match
if (extraction.supplier.bankgiro) {
const normalizedBg = normalizeBankgiro(extraction.supplier.bankgiro)
for (const supplier of suppliers) {
if (supplier.bankgiro && normalizeBankgiro(supplier.bankgiro) === normalizedBg) {
return {
supplierId: supplier.id,
supplierName: supplier.name,
confidence: 0.92,
matchMethod: 'bankgiro',
}
}
}
}
if (extraction.supplier.plusgiro) {
const normalizedPg = normalizeBankgiro(extraction.supplier.plusgiro)
for (const supplier of suppliers) {
if (supplier.plusgiro && normalizeBankgiro(supplier.plusgiro) === normalizedPg) {
return {
supplierId: supplier.id,
supplierName: supplier.name,
confidence: 0.92,
matchMethod: 'bankgiro',
}
}
}
}
// Pass 4: Fuzzy name match
if (extraction.supplier.name) {
let bestMatch: SupplierMatchResult | null = null
for (const supplier of suppliers) {
const similarity = calculateNameSimilarity(extraction.supplier.name, supplier.name)
const confidence = Math.round(similarity * 0.85 * 100) / 100 // Cap at 0.85 for name matches
if (confidence > 0.6 && (!bestMatch || confidence > bestMatch.confidence)) {
bestMatch = {
supplierId: supplier.id,
supplierName: supplier.name,
confidence,
matchMethod: 'fuzzy_name',
}
}
}
return bestMatch
}
return null
}
/**
* Normalize org number to digits only
*/
export function normalizeOrgNumber(orgNumber: string): string {
return orgNumber.replace(/\D/g, '')
}
/**
* Normalize VAT number to uppercase, no spaces
*/
export function normalizeVatNumber(vatNumber: string): string {
return vatNumber.replace(/\s/g, '').toUpperCase()
}
/**
* Normalize bankgiro/plusgiro to digits only
*/
export function normalizeBankgiro(value: string): string {
return value.replace(/\D/g, '')
}
/**
* Calculate name similarity with Swedish company suffix normalization
*/
export function calculateNameSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
const n1 = normalizeCompanyName(name1)
const n2 = normalizeCompanyName(name2)
if (n1 === n2) return 1
if (n1.includes(n2) || n2.includes(n1)) return 0.9
// Word overlap scoring
const words1 = n1.split(/\s+/).filter(Boolean)
const words2 = n2.split(/\s+/).filter(Boolean)
const commonWords = words1.filter((w) => words2.includes(w))
if (commonWords.length > 0) {
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
}
// Levenshtein similarity
const distance = levenshteinDistance(n1, n2)
const maxLength = Math.max(n1.length, n2.length)
return maxLength > 0 ? 1 - distance / maxLength : 0
}
/**
* Normalize Swedish company name for comparison.
* Strips common legal suffixes and normalizes whitespace.
*/
export function normalizeCompanyName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\såäöé]/g, '')
.replace(
/\b(ab|hb|kb|ek|ek\s*för|enskild\s*firma|aktiebolag|handelsbolag|kommanditbolag|ekonomisk\s*förening|stiftelse|ideell\s*förening|i\s*likvidation)\b/g,
''
)
.replace(/\s+/g, ' ')
.trim()
}
/**
* Calculate Levenshtein distance between two strings
*/
export function levenshteinDistance(str1: string, str2: string): number {
const m = str1.length
const n = str2.length
const dp: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
)
}
}
return dp[m][n]
}
@@ -1,25 +0,0 @@
{
"id": "invoice-inbox",
"sector": "general",
"exportName": "invoiceInboxExtension",
"entryPoint": "@/extensions/general/invoice-inbox",
"workspace": "@/components/extensions/general/DocumentInboxWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk"],
"definition": {
"name": "Dokumentinkorg",
"category": "import",
"icon": "Inbox",
"dataPattern": "manual",
"hasOwnData": true,
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.",
"quickAction": {
"label": "Dokumentinkorg",
"description": "Granska inkommande dokument",
"icon": "Inbox",
"href": "/e/general/invoice-inbox"
}
}
}
-41
View File
@@ -1,41 +0,0 @@
/**
* Invoice Inbox extension types.
*
* InvoiceExtractionResult, ExtractedInvoiceLineItem, and VatBreakdownItem
* now live in types/index.ts (used by core). Re-exported here for backward compat.
*/
export type {
InvoiceExtractionResult,
ExtractedInvoiceLineItem,
VatBreakdownItem,
} from '@/types'
export interface SupplierMatchResult {
supplierId: string
supplierName: string
confidence: number
matchMethod: 'org_number' | 'vat_number' | 'bankgiro' | 'fuzzy_name'
}
export interface InvoiceInboxSettings {
autoProcessEnabled: boolean
autoMatchSupplierEnabled: boolean
supplierMatchThreshold: number
inboxEmail: string | null
}
export interface ResendInboundPayload {
from: string
to: string
subject: string
html: string | null
text: string | null
attachments: ResendAttachment[]
created_at: string
}
export interface ResendAttachment {
filename: string
content_type: string
content: string // base64-encoded
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const mcpServerExtension: Extension = {
settingsPanel: {
label: 'MCP-server (API)',
path: '/settings?tab=api',
path: '/settings/api',
},
apiRoutes: [
@@ -1,261 +0,0 @@
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/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/receipt-matcher', () => ({
autoMatchReceipts: vi.fn().mockReturnValue([]),
}))
import { createClient } from '@/lib/supabase/server'
import { analyzeReceipt } from '../lib/receipt-analyzer'
import { autoMatchReceipts } from '../lib/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',
companyId: 'company-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',
companyId: 'company-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',
companyId: 'company-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',
companyId: 'company-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',
companyId: 'company-1',
},
})
expect(autoMatchReceipts).not.toHaveBeenCalled()
})
})
@@ -1,875 +0,0 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import type { ConfirmReceiptInput, Receipt, ReceiptLineItem, Transaction } from '@/types'
import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems, getDefaultClassification } from './lib/receipt-categorizer'
import { findTransactionMatches } from './lib/receipt-matcher'
import { getSettings, saveSettings } from './index'
// ============================================================
// / — GET: list receipts
// ============================================================
async function handleListReceipts(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const limit = parseInt(searchParams.get('limit') || '50', 10)
const offset = parseInt(searchParams.get('offset') || '0', 10)
let query = supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`, { count: 'exact' })
.eq('company_id', userId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (status) {
query = query.eq('status', status)
}
const { data, error, count } = await query
if (error) {
console.error('Receipts fetch error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({
data,
count,
limit,
offset,
})
}
// ============================================================
// /upload — POST: upload receipt image and extract data
// ============================================================
async function handleUpload(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const companyId = ctx!.companyId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
try {
const formData = await request.formData()
const imageFile = formData.get('image') as File | null
if (!imageFile) {
return NextResponse.json({ error: 'No image file provided' }, { status: 400 })
}
// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
if (!validTypes.includes(imageFile.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
{ status: 400 }
)
}
// Convert file to base64
const arrayBuffer = await imageFile.arrayBuffer()
const base64 = Buffer.from(arrayBuffer).toString('base64')
// Generate unique filename
const ext = imageFile.type.split('/')[1]
const filename = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('receipts')
.upload(filename, arrayBuffer, {
contentType: imageFile.type,
cacheControl: '3600',
})
if (uploadError) {
console.error('Storage upload error:', uploadError)
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
}
// WORM archive copy (non-blocking -- receipt flow continues even if this fails)
let wormDocumentId: string | null = null
try {
const { uploadDocument } = await import('@/lib/core/documents/document-service')
const wormDoc = await uploadDocument(supabase, userId, companyId, {
name: imageFile.name,
buffer: arrayBuffer,
type: imageFile.type,
}, { upload_source: 'camera' })
wormDocumentId = wormDoc.id
} catch (archiveErr) {
console.error('[receipt-upload] WORM archive copy failed:', archiveErr)
}
// Get public URL
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
const imageUrl = urlData.publicUrl
// Create receipt record with pending status
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: imageUrl,
status: 'processing',
document_id: wormDocumentId,
})
.select()
.single()
if (insertError) {
console.error('Receipt insert error:', insertError)
return NextResponse.json({ error: 'Failed to create receipt record' }, { status: 500 })
}
// Analyze receipt with Claude Vision
try {
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const extraction = await analyzeReceipt(base64, mimeType)
// Process and categorize line items
const processedLineItems = processLineItems(extraction.lineItems)
const { defaultIsBusiness } = getDefaultClassification(
extraction.flags.isRestaurant,
extraction.flags.isSystembolaget
)
// Update receipt with extracted data
const { error: updateError } = await supabase
.from('receipts')
.update({
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,
})
.eq('id', receipt.id)
if (updateError) {
console.error('Receipt update error:', updateError)
}
// 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,
category: item.category,
bas_account: item.basAccount,
is_business: defaultIsBusiness,
sort_order: index,
}))
const { error: lineItemsError } = await supabase
.from('receipt_line_items')
.insert(lineItemsToInsert)
if (lineItemsError) {
console.error('Line items insert error:', lineItemsError)
}
}
// Fetch the complete receipt with line items
const { data: completeReceipt, error: fetchError } = await supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`)
.eq('id', receipt.id)
.single()
if (fetchError) {
console.error('Fetch error:', fetchError)
return NextResponse.json({
data: {
id: receipt.id,
status: 'extracted',
extraction,
},
})
}
// Emit receipt.extracted event
const { eventBus } = await import('@/lib/events/bus')
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt: completeReceipt,
documentId: wormDocumentId,
confidence: extraction.confidence,
userId,
companyId,
},
})
return NextResponse.json({ data: completeReceipt })
} catch (analysisError) {
console.error('Receipt analysis error:', analysisError)
// Update receipt with error status
await supabase
.from('receipts')
.update({
status: 'error',
raw_extraction: {
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
},
})
.eq('id', receipt.id)
return NextResponse.json(
{
error: 'Failed to analyze receipt',
receiptId: receipt.id,
},
{ status: 500 }
)
}
} catch (error) {
console.error('Upload error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{ status: 500 }
)
}
}
// ============================================================
// /queue — GET: unmatched receipts and queue statistics
// ============================================================
async function handleGetQueue(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
// Parse query params
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const unmatched = searchParams.get('unmatched') === 'true'
// Build query
let query = supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*)
`)
.eq('company_id', userId)
.order('created_at', { ascending: false })
if (status) {
query = query.eq('status', status)
}
if (unmatched) {
query = query.is('matched_transaction_id', null)
}
const { data: receipts, error: receiptsError } = await query
if (receiptsError) {
console.error('Receipts fetch error:', receiptsError)
return NextResponse.json({ error: 'Failed to fetch receipts' }, { status: 500 })
}
// Get counts for queue summary
const { count: unmatchedReceiptsCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('company_id', userId)
.eq('status', 'confirmed')
.is('matched_transaction_id', null)
const { count: pendingReviewCount } = await supabase
.from('receipts')
.select('*', { count: 'exact', head: true })
.eq('company_id', userId)
.eq('status', 'extracted')
const { count: unmatchedTransactionsCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('company_id', userId)
.lt('amount', 0)
.is('receipt_id', null)
// Calculate streak (days with at least one categorized transaction)
const { data: recentActivity } = await supabase
.from('receipts')
.select('created_at')
.eq('company_id', userId)
.eq('status', 'confirmed')
.order('created_at', { ascending: false })
.limit(30)
let streakCount = 0
if (recentActivity && recentActivity.length > 0) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const activityDates = new Set(
recentActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
)
const checkDate = new Date(today)
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)
}
}
return NextResponse.json({
data: {
receipts,
summary: {
unmatched_receipts_count: unmatchedReceiptsCount || 0,
unmatched_transactions_count: unmatchedTransactionsCount || 0,
pending_review_count: pendingReviewCount || 0,
streak_count: streakCount,
},
},
})
}
// ============================================================
// /:id — GET: single receipt with line items
// ============================================================
async function handleGetReceipt(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
const { data, error } = await supabase
.from('receipts')
.select(`
*,
line_items:receipt_line_items(*),
matched_transaction:transactions(*)
`)
.eq('id', id)
.eq('company_id', userId)
.single()
if (error) {
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
// ============================================================
// /:id — DELETE: delete receipt and line items
// ============================================================
async function handleDeleteReceipt(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
// Get receipt to find image URL for cleanup
const { data: receipt } = await supabase
.from('receipts')
.select('image_url, matched_transaction_id')
.eq('id', id)
.eq('company_id', userId)
.single()
if (!receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Unlink from transaction if matched
if (receipt.matched_transaction_id) {
await supabase
.from('transactions')
.update({ receipt_id: null })
.eq('id', receipt.matched_transaction_id)
}
// Delete receipt (line items are cascade deleted)
const { error } = await supabase
.from('receipts')
.delete()
.eq('id', id)
.eq('company_id', userId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Optionally delete image from storage
if (receipt.image_url) {
try {
const urlParts = receipt.image_url.split('/receipts/')
if (urlParts[1]) {
await supabase.storage.from('receipts').remove([urlParts[1]])
}
} catch {
// Ignore storage cleanup errors
}
}
return NextResponse.json({ success: true })
}
// ============================================================
// /:id/confirm — POST: confirm line item classifications
// ============================================================
async function handleConfirm(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const companyId = ctx!.companyId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
// Verify receipt ownership
const { data: receipt, error: fetchError } = await supabase
.from('receipts')
.select('*')
.eq('id', id)
.eq('company_id', userId)
.single()
if (fetchError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
const body: ConfirmReceiptInput = await request.json()
// Update line items with classifications
if (body.line_items && body.line_items.length > 0) {
for (const item of body.line_items) {
const { error: updateError } = await supabase
.from('receipt_line_items')
.update({
is_business: item.is_business,
category: item.category || null,
bas_account: item.bas_account || null,
})
.eq('id', item.id)
.eq('receipt_id', id)
if (updateError) {
console.error('Line item update error:', updateError)
}
}
}
// Build receipt update
const receiptUpdate: Record<string, unknown> = {
status: 'confirmed',
}
// Add restaurant representation data if provided
if (body.representation_persons !== undefined) {
receiptUpdate.representation_persons = body.representation_persons
}
if (body.representation_purpose !== undefined) {
receiptUpdate.representation_purpose = body.representation_purpose
}
// Link to transaction if provided
if (body.matched_transaction_id) {
// Verify transaction ownership
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('id')
.eq('id', body.matched_transaction_id)
.eq('company_id', userId)
.single()
if (!txError && transaction) {
receiptUpdate.matched_transaction_id = body.matched_transaction_id
// Also update the transaction with the receipt link
await supabase
.from('transactions')
.update({ receipt_id: id })
.eq('id', body.matched_transaction_id)
}
}
// Update the receipt
const { data: updatedReceipt, error: updateError } = await supabase
.from('receipts')
.update(receiptUpdate)
.eq('id', id)
.select(`
*,
line_items:receipt_line_items(*)
`)
.single()
if (updateError) {
console.error('Receipt update error:', updateError)
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
}
// Calculate business/private totals from line items
const lineItems = ((updatedReceipt as unknown as Receipt).line_items || []) as ReceiptLineItem[]
let businessTotal = 0
let privateTotal = 0
for (const item of lineItems) {
if (item.is_business === true) {
businessTotal += item.line_total
} else if (item.is_business === false) {
privateTotal += item.line_total
}
}
// Emit receipt.confirmed event
const { eventBus } = await import('@/lib/events/bus')
await eventBus.emit({
type: 'receipt.confirmed',
payload: {
receipt: updatedReceipt as unknown as Receipt,
businessTotal: Math.round(businessTotal * 100) / 100,
privateTotal: Math.round(privateTotal * 100) / 100,
userId,
companyId,
},
})
return NextResponse.json({ data: updatedReceipt })
}
// ============================================================
// /:id/match — POST: find potential transaction matches
// ============================================================
async function handleFindMatches(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
// Fetch receipt
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.select('*')
.eq('id', id)
.eq('company_id', userId)
.single()
if (receiptError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Get date range for transaction search (+/-7 days from receipt date)
const receiptDate = receipt.receipt_date ? new Date(receipt.receipt_date) : new Date()
const startDate = new Date(receiptDate)
startDate.setDate(startDate.getDate() - 7)
const endDate = new Date(receiptDate)
endDate.setDate(endDate.getDate() + 7)
// Fetch unmatched transactions in date range
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', userId)
.is('receipt_id', null)
.lt('amount', 0) // Only expenses
.gte('date', startDate.toISOString().split('T')[0])
.lte('date', endDate.toISOString().split('T')[0])
.order('date', { ascending: false })
if (txError) {
console.error('Transaction fetch error:', txError)
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
// Find matches
const matches = findTransactionMatches(
receipt as unknown as Receipt,
transactions as Transaction[]
)
return NextResponse.json({
data: {
receipt_id: id,
matches: matches.slice(0, 5), // Return top 5 matches
},
})
}
// ============================================================
// /:id/match — PATCH: link receipt to a specific transaction
// ============================================================
async function handleLinkMatch(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const companyId = ctx!.companyId
const { createClient } = await import('@/lib/supabase/server')
const supabase = await createClient()
const { searchParams } = new URL(request.url)
const id = searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Receipt ID is required' }, { status: 400 })
}
const body = await request.json()
const { transaction_id, match_confidence } = body
if (!transaction_id) {
return NextResponse.json({ error: 'transaction_id is required' }, { status: 400 })
}
// Verify receipt ownership
const { data: receipt, error: receiptError } = await supabase
.from('receipts')
.select('*, line_items:receipt_line_items(*)')
.eq('id', id)
.eq('company_id', userId)
.single()
if (receiptError || !receipt) {
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
}
// Verify transaction ownership
const { data: transaction, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('id', transaction_id)
.eq('company_id', userId)
.single()
if (txError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Update receipt with match
const { error: updateReceiptError } = await supabase
.from('receipts')
.update({
matched_transaction_id: transaction_id,
match_confidence: match_confidence || null,
})
.eq('id', id)
if (updateReceiptError) {
console.error('Receipt update error:', updateReceiptError)
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
}
// Update transaction with receipt link
const { error: updateTxError } = await supabase
.from('transactions')
.update({ receipt_id: id })
.eq('id', transaction_id)
if (updateTxError) {
console.error('Transaction update error:', updateTxError)
}
// Emit receipt.matched event
const { eventBus } = await import('@/lib/events/bus')
await eventBus.emit({
type: 'receipt.matched',
payload: {
receipt: receipt as unknown as Receipt,
transaction: transaction as Transaction,
confidence: match_confidence || 0,
autoMatched: false,
userId,
companyId,
},
})
return NextResponse.json({
data: {
receipt_id: id,
transaction_id,
matched: true,
},
})
}
// ============================================================
// /settings — GET: get current settings
// ============================================================
async function handleGetSettings(
_request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const settings = await getSettings(userId)
return NextResponse.json({ data: settings })
}
// ============================================================
// /settings — PUT: update settings
// ============================================================
async function handleUpdateSettings(
request: Request,
ctx?: ExtensionContext
): Promise<Response> {
const userId = ctx!.userId
const body = await request.json()
// Validate setting keys
const allowedKeys = [
'autoOcrEnabled',
'autoMatchEnabled',
'autoMatchThreshold',
'ocrConfidenceThreshold',
]
const filtered: Record<string, unknown> = {}
for (const key of allowedKeys) {
if (key in body) {
filtered[key] = body[key]
}
}
if (Object.keys(filtered).length === 0) {
return NextResponse.json({ error: 'No valid settings provided' }, { status: 400 })
}
const settings = await saveSettings(userId, filtered)
return NextResponse.json({ data: settings })
}
// ============================================================
// Route definitions
// ============================================================
export const receiptOcrApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/',
handler: handleListReceipts,
},
{
method: 'POST',
path: '/upload',
handler: handleUpload,
},
{
method: 'GET',
path: '/queue',
handler: handleGetQueue,
},
{
method: 'GET',
path: '/:id',
handler: handleGetReceipt,
},
{
method: 'DELETE',
path: '/:id',
handler: handleDeleteReceipt,
},
{
method: 'POST',
path: '/:id/confirm',
handler: handleConfirm,
},
{
method: 'POST',
path: '/:id/match',
handler: handleFindMatches,
},
{
method: 'PATCH',
path: '/:id/match',
handler: handleLinkMatch,
},
{
method: 'GET',
path: '/settings',
handler: handleGetSettings,
},
{
method: 'PUT',
path: '/settings',
handler: handleUpdateSettings,
},
]
@@ -1,336 +0,0 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Camera, X, RotateCcw, Check, Loader2, SunMedium, Move, Focus } from 'lucide-react'
import type { CameraQualityFeedback } from '@/types'
interface ReceiptCameraProps {
onCapture: (imageData: string, mimeType: string) => Promise<void>
onClose: () => void
}
export default function ReceiptCamera({ onCapture, onClose }: ReceiptCameraProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const [isStreaming, setIsStreaming] = useState(false)
const [capturedImage, setCapturedImage] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [quality, setQuality] = useState<CameraQualityFeedback>({
lightingOk: false,
distanceOk: false,
focusOk: false,
readyToCapture: false,
})
// Start camera stream
const startCamera = useCallback(async () => {
try {
setError(null)
const constraints: MediaStreamConstraints = {
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 },
},
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
try {
await videoRef.current.play()
setIsStreaming(true)
} catch (playErr) {
// Ignore AbortError - this happens when stream is interrupted (e.g., component unmount)
if (playErr instanceof Error && playErr.name === 'AbortError') {
console.log('Video play interrupted - this is normal during navigation')
return
}
throw playErr
}
}
} catch (err) {
console.error('Camera access error:', err)
// Don't show error for AbortError
if (err instanceof Error && err.name === 'AbortError') return
setError('Kunde inte komma åt kameran. Kontrollera att du har gett tillåtelse.')
}
}, [])
// Stop camera stream
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
setIsStreaming(false)
}, [])
// Analyze video frame for quality feedback
const analyzeQuality = useCallback(() => {
if (!videoRef.current || !canvasRef.current || !isStreaming) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas size to match video
canvas.width = video.videoWidth / 4 // Downsample for analysis
canvas.height = video.videoHeight / 4
// Draw current frame
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// Get image data for analysis
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
// Calculate average brightness
let totalBrightness = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
totalBrightness += (r + g + b) / 3
}
const avgBrightness = totalBrightness / (data.length / 4)
// Calculate contrast (standard deviation of brightness)
let sumSquares = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
const brightness = (r + g + b) / 3
sumSquares += Math.pow(brightness - avgBrightness, 2)
}
const contrast = Math.sqrt(sumSquares / (data.length / 4))
// Quality assessment
const lightingOk = avgBrightness > 80 && avgBrightness < 200
const focusOk = contrast > 30 // Higher contrast suggests better focus
const distanceOk = true // Would need edge detection for proper distance check
const newQuality: CameraQualityFeedback = {
lightingOk,
distanceOk,
focusOk,
readyToCapture: lightingOk && focusOk && distanceOk,
}
if (!lightingOk) {
newQuality.message = avgBrightness < 80 ? 'För mörkt - öka ljuset' : 'För ljust - minska ljuset'
} else if (!focusOk) {
newQuality.message = 'Försök hålla kameran stilla'
}
setQuality(newQuality)
}, [isStreaming])
// Quality analysis loop
useEffect(() => {
if (!isStreaming) return
const interval = setInterval(analyzeQuality, 500)
return () => clearInterval(interval)
}, [isStreaming, analyzeQuality])
// Start camera on mount
useEffect(() => {
startCamera()
return () => stopCamera()
}, [startCamera, stopCamera])
// Capture image
const captureImage = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas to full resolution
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Draw the current video frame
ctx.drawImage(video, 0, 0)
// Convert to base64
const imageData = canvas.toDataURL('image/jpeg', 0.9)
setCapturedImage(imageData)
stopCamera()
}, [stopCamera])
// Retake photo
const retake = useCallback(() => {
setCapturedImage(null)
startCamera()
}, [startCamera])
// Confirm and upload
const confirmCapture = async () => {
if (!capturedImage) return
setIsProcessing(true)
try {
// Extract base64 data (remove data:image/jpeg;base64, prefix)
const base64Data = capturedImage.split(',')[1]
await onCapture(base64Data, 'image/jpeg')
} catch (err) {
console.error('Upload error:', err)
setError('Kunde inte ladda upp bilden. Försök igen.')
setIsProcessing(false)
}
}
// Render captured image review
if (capturedImage) {
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<Button variant="ghost" size="icon" onClick={retake} disabled={isProcessing}>
<RotateCcw className="h-5 w-5 text-white" />
</Button>
<h2 className="text-white font-medium">Granska bild</h2>
<Button variant="ghost" size="icon" onClick={onClose} disabled={isProcessing}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Image preview - centered */}
<div className="absolute inset-0 flex items-center justify-center p-4 pt-20 pb-32">
<img
src={capturedImage}
alt="Captured receipt"
className="max-h-full max-w-full object-contain rounded-lg"
/>
</div>
{/* Action buttons - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex gap-4 justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8 px-4"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<Button variant="outline" onClick={retake} disabled={isProcessing} className="bg-white/10 border-white text-white hover:bg-white/20">
<RotateCcw className="mr-2 h-4 w-4" />
Ta om
</Button>
<Button onClick={confirmCapture} disabled={isProcessing} className="bg-white text-black hover:bg-white/90">
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyserar...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Använd bild
</>
)}
</Button>
</div>
</div>
)
}
// Render camera view
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<div className="w-10" />
<h2 className="text-white font-medium">Skanna kvitto</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Video preview - fills the screen */}
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 h-full w-full object-cover"
/>
{/* Quality feedback overlay */}
{isStreaming && (
<div className="absolute bottom-36 left-0 right-0 flex justify-center z-10">
<div className="bg-black/70 rounded-full px-4 py-2 flex items-center gap-4">
<div className={`flex items-center gap-1 ${quality.lightingOk ? 'text-success' : 'text-yellow-400'}`}>
<SunMedium className="h-4 w-4" />
<span className="text-xs">Ljus</span>
</div>
<div className={`flex items-center gap-1 ${quality.distanceOk ? 'text-success' : 'text-yellow-400'}`}>
<Move className="h-4 w-4" />
<span className="text-xs">Avstånd</span>
</div>
<div className={`flex items-center gap-1 ${quality.focusOk ? 'text-success' : 'text-yellow-400'}`}>
<Focus className="h-4 w-4" />
<span className="text-xs">Fokus</span>
</div>
</div>
</div>
)}
{/* Quality message */}
{quality.message && (
<div className="absolute top-20 left-0 right-0 flex justify-center z-10">
<div className="bg-warning text-warning-foreground px-4 py-2 rounded-full text-sm font-medium">
{quality.message}
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-30">
<div className="text-center p-4">
<p className="text-white mb-4">{error}</p>
<Button onClick={startCamera}>Försök igen</Button>
</div>
</div>
)}
{/* Guide overlay */}
{isStreaming && (
<div className="absolute inset-0 pointer-events-none z-10">
<div className="absolute top-20 left-8 right-8 bottom-36 border-2 border-white/30 rounded-lg">
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-white rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-white rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-white rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-white rounded-br-lg" />
</div>
</div>
)}
{/* Capture button - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<button
onClick={captureImage}
disabled={!isStreaming}
className="h-20 w-20 rounded-full border-4 border-white flex items-center justify-center bg-white/20 active:bg-white/40 disabled:opacity-50 transition-colors shadow-2xl backdrop-blur-sm"
>
<Camera className="h-8 w-8 text-white" />
</button>
</div>
{/* Hidden canvas for image processing */}
<canvas ref={canvasRef} className="hidden" />
</div>
)
}
@@ -1,169 +0,0 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Flame, Receipt, CreditCard, Camera, ArrowRight } from 'lucide-react'
import type { ReceiptQueueSummary } from '@/types'
interface ReceiptDashboardProps {
summary: ReceiptQueueSummary
onScanReceipt: () => void
onViewReceiptQueue: () => void
onViewTransactionQueue: () => void
}
export default function ReceiptDashboard({
summary,
onScanReceipt,
onViewReceiptQueue,
onViewTransactionQueue,
}: ReceiptDashboardProps) {
const hasWork =
summary.unmatched_receipts_count > 0 ||
summary.unmatched_transactions_count > 0 ||
summary.pending_review_count > 0
return (
<div className="space-y-4">
{/* Quick actions */}
<div className="grid grid-cols-1 gap-3">
<Button
variant="default"
className="h-auto py-4 flex-col gap-2"
onClick={onScanReceipt}
>
<Camera className="h-6 w-6" />
<span>Skanna kvitto</span>
</Button>
</div>
{/* Streak counter */}
{summary.streak_count > 0 && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<Flame className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-bold">{summary.streak_count}</p>
<p className="text-sm text-muted-foreground">dagars streak!</p>
</div>
</div>
<p className="text-xs text-muted-foreground max-w-24 text-right">
Fortsätt bokföra varje dag
</p>
</div>
</CardContent>
</Card>
)}
{/* Queue cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Pending review */}
{summary.pending_review_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewReceiptQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<Receipt className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Att granska</p>
<p className="text-sm text-muted-foreground">
{summary.pending_review_count} kvitton
</p>
</div>
</div>
<Badge variant="secondary">{summary.pending_review_count}</Badge>
</div>
</CardContent>
</Card>
)}
{/* Unmatched receipts */}
{summary.unmatched_receipts_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewReceiptQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Receipt className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">Kvitton utan transaktion</p>
<p className="text-sm text-muted-foreground">
{summary.unmatched_receipts_count} st
</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
{/* Unmatched transactions */}
{summary.unmatched_transactions_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewTransactionQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<CreditCard className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Transaktioner utan kvitto</p>
<p className="text-sm text-muted-foreground">
{summary.unmatched_transactions_count} st
</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
</div>
{/* All done state */}
{!hasWork && (
<Card className="bg-success/5 border-success/20">
<CardContent className="pt-4 text-center py-8">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
<svg
className="h-8 w-8 text-success"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="font-semibold text-lg">Allt är uppdaterat!</h3>
<p className="text-sm text-muted-foreground mt-1">
Inga kvitton eller transaktioner att granska
</p>
</CardContent>
</Card>
)}
</div>
)
}
@@ -1,99 +0,0 @@
'use client'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/utils'
import { getExpenseCategories, CATEGORY_LABELS } from '../lib/receipt-categorizer'
import type { ReceiptLineItem, TransactionCategory } from '@/types'
interface ReceiptLineItemRowProps {
item: ReceiptLineItem
onToggleBusiness: (id: string, isBusiness: boolean) => void
onCategoryChange: (id: string, category: TransactionCategory) => void
disabled?: boolean
}
export default function ReceiptLineItemRow({
item,
onToggleBusiness,
onCategoryChange,
disabled = false,
}: ReceiptLineItemRowProps) {
const expenseCategories = getExpenseCategories()
const isBusiness = item.is_business === true
return (
<div className="flex flex-col gap-2 p-3 border rounded-lg bg-card">
{/* Description and amount */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.description}</p>
{item.quantity !== 1 && (
<p className="text-xs text-muted-foreground">
{item.quantity} x {item.unit_price ? formatCurrency(item.unit_price, 'SEK') : '—'}
</p>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="font-semibold">{formatCurrency(item.line_total, 'SEK')}</p>
{item.vat_rate && (
<p className="text-xs text-muted-foreground">{item.vat_rate}% moms</p>
)}
</div>
</div>
{/* Classification controls */}
<div className="flex items-center gap-3 pt-2 border-t">
{/* Business/Private toggle */}
<div className="flex items-center gap-2">
<span className={`text-xs ${!isBusiness ? 'text-muted-foreground' : 'font-medium'}`}>
Privat
</span>
<Switch
checked={isBusiness}
onCheckedChange={(checked) => onToggleBusiness(item.id, checked)}
disabled={disabled}
/>
<span className={`text-xs ${isBusiness ? 'font-medium' : 'text-muted-foreground'}`}>
Företag
</span>
</div>
{/* Category selector (only for business) */}
{isBusiness && (
<Select
value={item.category || undefined}
onValueChange={(value) => onCategoryChange(item.id, value as TransactionCategory)}
disabled={disabled}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="Kategori" />
</SelectTrigger>
<SelectContent>
{expenseCategories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Confidence badge */}
{item.extraction_confidence !== null && item.extraction_confidence < 0.8 && (
<Badge variant="outline" className="text-xs">
{Math.round((item.extraction_confidence || 0) * 100)}%
</Badge>
)}
{/* AI suggestion indicator */}
{item.suggested_category && item.is_business === null && (
<Badge variant="secondary" className="text-xs">
Förslag: {CATEGORY_LABELS[`expense_${item.suggested_category}` as TransactionCategory] || item.suggested_category}
</Badge>
)}
</div>
</div>
)
}
@@ -1,418 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
AlertTriangle,
Check,
X,
Building,
User,
Wine,
Utensils,
Globe,
ChevronUp,
ChevronDown,
Loader2,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { calculateReceiptSplit, getDefaultClassification } from '../lib/receipt-categorizer'
import { calculateRepresentationLimits } from '../lib/receipt-utils'
import ReceiptLineItemRow from './ReceiptLineItemRow'
import type { Receipt, ReceiptLineItem, TransactionCategory, ConfirmLineItemInput } from '@/types'
interface ReceiptReviewViewProps {
receipt: Receipt & { line_items: ReceiptLineItem[] }
onConfirm: (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => Promise<void>
onCancel: () => void
onFindMatches?: () => void
}
export default function ReceiptReviewView({
receipt,
onConfirm,
onCancel,
onFindMatches,
}: ReceiptReviewViewProps) {
// Initialize line items with their current state
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>(receipt.line_items || [])
const [isProcessing, setIsProcessing] = useState(false)
const [showImage, setShowImage] = useState(false)
// Restaurant representation state
const [representationPersons, setRepresentationPersons] = useState<number | undefined>(
receipt.representation_persons || undefined
)
const [representationPurpose, setRepresentationPurpose] = useState<string>(
receipt.representation_purpose || ''
)
// Get default classification info
const defaultClassification = getDefaultClassification(
receipt.is_restaurant,
receipt.is_systembolaget
)
// Calculate split summary
const splitSummary = useMemo(() => {
return calculateReceiptSplit(lineItems.map(item => ({
lineTotal: item.line_total,
is_business: item.is_business,
})))
}, [lineItems])
// Calculate representation limits if restaurant
const representationCalc = useMemo(() => {
if (!receipt.is_restaurant || !representationPersons || !receipt.total_amount) return null
return calculateRepresentationLimits(receipt.total_amount, representationPersons)
}, [receipt.is_restaurant, receipt.total_amount, representationPersons])
// Toggle business status for a line item
const handleToggleBusiness = (id: string, isBusiness: boolean) => {
setLineItems((items) =>
items.map((item) =>
item.id === id
? { ...item, is_business: isBusiness, category: isBusiness ? item.category : null }
: item
)
)
}
// Change category for a line item
const handleCategoryChange = (id: string, category: TransactionCategory) => {
setLineItems((items) =>
items.map((item) => (item.id === id ? { ...item, category } : item))
)
}
// Mark all as business
const handleAllBusiness = () => {
setLineItems((items) =>
items.map((item) => ({
...item,
is_business: true,
category: item.category || 'expense_other',
}))
)
}
// Mark all as private
const handleAllPrivate = () => {
setLineItems((items) =>
items.map((item) => ({
...item,
is_business: false,
category: null,
}))
)
}
// Confirm and save
const handleConfirm = async () => {
// Validate restaurant representation
if (receipt.is_restaurant && splitSummary.businessTotal > 0) {
if (!representationPersons || representationPersons < 1) {
alert('Ange antal personer för restaurangrepresentation')
return
}
if (!representationPurpose.trim()) {
alert('Ange syfte för restaurangrepresentation')
return
}
}
setIsProcessing(true)
try {
await onConfirm({
line_items: lineItems.map((item) => ({
id: item.id,
is_business: item.is_business ?? false,
category: item.category || undefined,
bas_account: item.bas_account || undefined,
})),
representation_persons: representationPersons,
representation_purpose: representationPurpose.trim() || undefined,
})
} catch (error) {
console.error('Confirm error:', error)
} finally {
setIsProcessing(false)
}
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
<Button variant="ghost" size="icon" onClick={onCancel} disabled={isProcessing} aria-label="Avbryt">
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Granska kvitto</h1>
<div className="w-10" />
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Merchant and total card */}
<Card>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div>
<h2 className="font-semibold text-lg">{receipt.merchant_name || 'Okänd handlare'}</h2>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
)}
</div>
<div className="text-right">
<p className="text-2xl font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
{receipt.vat_amount && (
<p className="text-sm text-muted-foreground">
varav moms {formatCurrency(receipt.vat_amount, receipt.currency)}
</p>
)}
</div>
</div>
{/* Special flags */}
<div className="flex flex-wrap gap-2 mt-3">
{receipt.is_restaurant && (
<Badge variant="secondary">
<Utensils className="mr-1 h-3 w-3" /> Restaurang
</Badge>
)}
{receipt.is_systembolaget && (
<Badge variant="secondary">
<Wine className="mr-1 h-3 w-3" /> Systembolaget
</Badge>
)}
{receipt.is_foreign_merchant && (
<Badge variant="secondary">
<Globe className="mr-1 h-3 w-3" /> Utländskt
</Badge>
)}
{receipt.extraction_confidence && (
<Badge
variant={receipt.extraction_confidence > 0.8 ? 'outline' : receipt.extraction_confidence > 0.5 ? 'warning' : 'destructive'}
className={receipt.extraction_confidence > 0.8 ? 'border-success/50 text-success' : ''}
>
{Math.round(receipt.extraction_confidence * 100)}% säkerhet
{receipt.extraction_confidence <= 0.8 && ' — kontrollera'}
</Badge>
)}
</div>
{/* Toggle image preview */}
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={() => setShowImage(!showImage)}
>
{showImage ? <ChevronUp className="mr-1 h-3 w-3" /> : <ChevronDown className="mr-1 h-3 w-3" />}
{showImage ? 'Dölj bild' : 'Visa bild'}
</Button>
{showImage && receipt.image_url && (
<img
src={receipt.image_url}
alt="Receipt"
className="mt-2 max-h-48 rounded-lg object-contain"
/>
)}
</CardContent>
</Card>
{/* Warning message */}
{defaultClassification.warningMessage && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/5 border border-warning/30">
<AlertTriangle className="h-5 w-5 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-sm text-warning-foreground">
{defaultClassification.warningMessage}
</p>
</div>
)}
{/* Restaurant representation section */}
{receipt.is_restaurant && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Utensils className="h-4 w-4" />
Representation
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="persons" className="text-xs">
Antal personer
</Label>
<Input
id="persons"
type="number"
min={1}
value={representationPersons || ''}
onChange={(e) => setRepresentationPersons(parseInt(e.target.value) || undefined)}
placeholder="2"
/>
</div>
<div>
<Label className="text-xs">Per person</Label>
<p className="h-10 flex items-center font-medium">
{representationPersons && receipt.total_amount
? formatCurrency(receipt.total_amount / representationPersons, receipt.currency)
: '—'}
</p>
</div>
</div>
<div>
<Label htmlFor="purpose" className="text-xs">
Syfte med representationen
</Label>
<Textarea
id="purpose"
value={representationPurpose}
onChange={(e) => setRepresentationPurpose(e.target.value)}
placeholder="T.ex. kundmöte, kontraktsförhandling..."
rows={2}
/>
</div>
{representationCalc && (
<div className="p-2 rounded bg-muted text-xs space-y-1">
<p>
<span className="text-muted-foreground">Avdragsgill del:</span>{' '}
<span className="font-medium text-success">
{formatCurrency(representationCalc.deductibleAmount, receipt.currency)}
</span>
{' '}(max {representationCalc.maxDeductiblePerPerson} kr/person)
</p>
{representationCalc.nonDeductibleAmount > 0 && (
<p>
<span className="text-muted-foreground">Ej avdragsgill del:</span>{' '}
<span className="font-medium text-orange-600">
{formatCurrency(representationCalc.nonDeductibleAmount, receipt.currency)}
</span>
</p>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Quick actions */}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleAllBusiness}>
<Building className="mr-2 h-4 w-4" />
Allt företag
</Button>
<Button variant="outline" className="flex-1" onClick={handleAllPrivate}>
<User className="mr-2 h-4 w-4" />
Allt privat
</Button>
</div>
{/* Line items */}
<div className="space-y-2">
<h3 className="font-medium text-sm flex items-center justify-between">
Artiklar ({lineItems.length})
<span className="text-muted-foreground font-normal">
Svajpa eller klicka för att klassificera
</span>
</h3>
{lineItems.map((item) => (
<ReceiptLineItemRow
key={item.id}
item={item}
onToggleBusiness={handleToggleBusiness}
onCategoryChange={handleCategoryChange}
disabled={isProcessing}
/>
))}
</div>
{/* Summary */}
<Card>
<CardContent className="pt-4">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Företag:</span>
<span className="font-medium text-success">
{formatCurrency(splitSummary.businessTotal, receipt.currency)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Privat:</span>
<span className="font-medium text-orange-600">
{formatCurrency(splitSummary.privateTotal, receipt.currency)}
</span>
</div>
{splitSummary.unclassifiedTotal > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Oklassificerat:</span>
<span className="font-medium text-yellow-600">
{formatCurrency(splitSummary.unclassifiedTotal, receipt.currency)}
</span>
</div>
)}
<div className="pt-2 border-t flex justify-between font-medium">
<span>Totalt:</span>
<span>{formatCurrency(receipt.total_amount || 0, receipt.currency)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Footer actions - large touch targets for mobile */}
<div className="p-4 border-t flex-shrink-0 space-y-2 safe-area-bottom">
{onFindMatches && (
<Button
variant="outline"
className="w-full h-12 text-base"
onClick={onFindMatches}
disabled={isProcessing}
>
Hitta matchande transaktion
</Button>
)}
<Button
className="w-full h-14 text-base font-medium"
onClick={handleConfirm}
disabled={isProcessing || splitSummary.unclassifiedTotal > 0}
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Sparar...
</>
) : (
<>
<Check className="mr-2 h-5 w-5" />
Bekräfta klassificering
</>
)}
</Button>
{splitSummary.unclassifiedTotal > 0 && (
<p className="text-xs text-center text-muted-foreground">
Klassificera alla artiklar innan du bekräftar
</p>
)}
</div>
</div>
)
}
@@ -1,229 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { formatCurrency, formatDate } from '@/lib/utils'
import { X, Check, Link2, AlertCircle, Search, Loader2 } from 'lucide-react'
import type { Receipt, ReceiptMatchCandidate } from '@/types'
interface TransactionMatcherProps {
receipt: Receipt
onMatch: (transactionId: string, confidence: number) => Promise<void>
onSkip: () => void
onClose: () => void
}
export default function TransactionMatcher({
receipt,
onMatch,
onSkip,
onClose,
}: TransactionMatcherProps) {
const [matches, setMatches] = useState<ReceiptMatchCandidate[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isMatching, setIsMatching] = useState(false)
const [selectedMatch, setSelectedMatch] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Fetch matches on mount
useEffect(() => {
const fetchMatches = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/extensions/ext/receipt-ocr/${receipt.id}/match`, {
method: 'POST',
})
const data = await response.json()
if (response.ok && data.data?.matches) {
setMatches(data.data.matches)
} else {
setError(data.error || 'Kunde inte hämta matchningar')
}
} catch {
setError('Nätverksfel vid hämtning av matchningar')
} finally {
setIsLoading(false)
}
}
fetchMatches()
}, [receipt.id])
// Handle match confirmation
const handleConfirmMatch = async () => {
if (!selectedMatch) return
const match = matches.find((m) => m.transaction.id === selectedMatch)
if (!match) return
setIsMatching(true)
try {
await onMatch(selectedMatch, match.confidence)
} catch {
setError('Kunde inte koppla kvitto till transaktion')
} finally {
setIsMatching(false)
}
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Matcha transaktion</h1>
<div className="w-10" />
</div>
{/* Receipt summary */}
<Card className="m-4 mb-2">
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{receipt.merchant_name || 'Kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
)}
</div>
<p className="text-xl font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
</div>
</CardContent>
</Card>
{/* Content */}
<div className="flex-1 overflow-auto px-4 pb-4">
<h2 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
<Search className="h-4 w-4" />
Möjliga matchningar
</h2>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="pt-4">
<Skeleton className="h-4 w-2/3 mb-2" />
<Skeleton className="h-6 w-1/3 mb-2" />
<Skeleton className="h-3 w-1/2" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<Card className="border-destructive">
<CardContent className="pt-4 flex items-center gap-3 text-destructive">
<AlertCircle className="h-5 w-5" />
<p>{error}</p>
</CardContent>
</Card>
) : matches.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="font-medium">Inga matchande transaktioner hittades</p>
<p className="text-sm text-muted-foreground mt-1">
Det finns ingen banktransaktion som matchar detta kvitto
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{matches.map((match) => (
<Card
key={match.transaction.id}
className={`cursor-pointer transition-colors ${
selectedMatch === match.transaction.id
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/50'
}`}
onClick={() => setSelectedMatch(match.transaction.id)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{match.transaction.description}</p>
<p className="text-sm text-muted-foreground">
{formatDate(match.transaction.date)}
</p>
</div>
<div className="text-right flex-shrink-0">
<p className="font-bold">
{formatCurrency(Math.abs(match.transaction.amount), match.transaction.currency)}
</p>
<Badge
variant={match.confidence > 0.8 ? 'default' : match.confidence > 0.6 ? 'secondary' : 'outline'}
>
{Math.round(match.confidence * 100)}% match
</Badge>
</div>
</div>
{/* Match reasons */}
<div className="flex flex-wrap gap-1 mt-2">
{match.matchReasons.map((reason, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{reason}
</Badge>
))}
</div>
{/* Variance info */}
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
{match.dateVariance > 0 && (
<span>Datum: ±{Math.round(match.dateVariance)} dagar</span>
)}
{match.amountVariance > 0.01 && (
<span>Belopp: ±{Math.round(match.amountVariance * 100)}%</span>
)}
</div>
{/* Selected indicator */}
{selectedMatch === match.transaction.id && (
<div className="absolute top-2 right-2">
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
<Check className="h-4 w-4 text-primary-foreground" />
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Footer actions */}
<div className="p-4 border-t flex gap-3">
<Button variant="outline" className="flex-1" onClick={onSkip}>
Hoppa över
</Button>
<Button
className="flex-1"
onClick={handleConfirmMatch}
disabled={!selectedMatch || isMatching}
>
{isMatching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar...
</>
) : (
<>
<Link2 className="mr-2 h-4 w-4" />
Koppla transaktion
</>
)}
</Button>
</div>
</div>
)
}
@@ -1,5 +0,0 @@
export { default as ReceiptCamera } from './ReceiptCamera'
export { default as ReceiptLineItemRow } from './ReceiptLineItemRow'
export { default as ReceiptReviewView } from './ReceiptReviewView'
export { default as TransactionMatcher } from './TransactionMatcher'
export { default as ReceiptDashboard } from './ReceiptDashboard'
-341
View File
@@ -1,341 +0,0 @@
import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems } from './lib/receipt-categorizer'
import { autoMatchReceipts } from './lib/receipt-matcher'
import { receiptOcrApiRoutes } from './api-routes'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { Receipt } 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,
}
/** 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
.from('extension_data')
.select('value')
.eq('company_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 { createClient } = await import('@/lib/supabase/server')
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'>,
ctx?: ExtensionContext
): Promise<void> {
const { document, userId, companyId } = payload
const log = ctx?.log ?? console
// Gate: Is it an image?
if (!document.mime_type || !IMAGE_MIME_TYPES.includes(document.mime_type)) {
return
}
// Gate: Is autoOcrEnabled?
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoOcrEnabled) {
return
}
log.info(`Auto-OCR triggered for document ${document.id}`)
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Download image from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('documents')
.download(document.storage_path)
if (downloadError || !fileData) {
log.error('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) {
log.info(
`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) {
log.error('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,
category: item.category,
bas_account: item.basAccount,
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
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,
documentId: document.id,
confidence: extraction.confidence,
userId,
companyId,
},
})
log.info(`Receipt ${receipt.id} created from document ${document.id}`)
} catch (error) {
log.error('handleDocumentUploaded failed:', error)
}
}
/**
* When new transactions arrive from banking sync, auto-match unmatched receipts.
*/
async function handleTransactionSynced(
payload: EventPayload<'transaction.synced'>,
ctx?: ExtensionContext
): Promise<void> {
const { transactions: syncedTransactions, userId, companyId } = payload
const log = ctx?.log ?? console
// Gate: Is autoMatchEnabled?
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
if (!settings.autoMatchEnabled) {
return
}
// Only consider expense transactions
const expenseTransactions = syncedTransactions.filter((t) => t.amount < 0)
if (expenseTransactions.length === 0) {
return
}
log.info(`Auto-match triggered for ${expenseTransactions.length} expense transactions`)
try {
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
// Fetch unmatched receipts
const { data: unmatchedReceipts, error: fetchError } = await supabase
.from('receipts')
.select('*, line_items:receipt_line_items(*)')
.eq('company_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
)
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
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 emit({
type: 'receipt.matched',
payload: {
receipt,
transaction: match.transaction,
confidence: match.confidence,
autoMatched: true,
userId,
companyId,
},
})
log.info(
`Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})`
)
}
} catch (error) {
log.error('handleTransactionSynced failed:', error)
}
}
// ============================================================
// Extension Object
// ============================================================
export const receiptOcrExtension: Extension = {
id: 'receipt-ocr',
name: 'Receipt OCR',
version: '1.0.0',
sector: 'general',
apiRoutes: receiptOcrApiRoutes,
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 ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
@@ -1,154 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
mapSuggestedCategory,
getBASAccount,
categorizeLineItem,
processLineItems,
calculateReceiptSplit,
getDefaultClassification,
} from '../receipt-categorizer'
import type { ExtractedLineItem } from '@/types'
describe('mapSuggestedCategory', () => {
it('maps AI categories to TransactionCategory', () => {
expect(mapSuggestedCategory('equipment')).toBe('expense_equipment')
expect(mapSuggestedCategory('software')).toBe('expense_software')
expect(mapSuggestedCategory('travel')).toBe('expense_travel')
expect(mapSuggestedCategory('office')).toBe('expense_office')
expect(mapSuggestedCategory('marketing')).toBe('expense_marketing')
expect(mapSuggestedCategory('professional_services')).toBe('expense_professional_services')
expect(mapSuggestedCategory('education')).toBe('expense_education')
expect(mapSuggestedCategory('other')).toBe('expense_other')
})
it('returns null for unknown categories', () => {
expect(mapSuggestedCategory('nonexistent')).toBeNull()
expect(mapSuggestedCategory(null)).toBeNull()
})
})
describe('getBASAccount', () => {
it('returns correct BAS account per category', () => {
expect(getBASAccount('expense_equipment')).toBe('5410')
expect(getBASAccount('expense_software')).toBe('5420')
expect(getBASAccount('expense_travel')).toBe('5800')
expect(getBASAccount('expense_office')).toBe('5010')
expect(getBASAccount('expense_marketing')).toBe('5910')
expect(getBASAccount('expense_professional_services')).toBe('6530')
expect(getBASAccount('expense_education')).toBe('6991')
expect(getBASAccount('expense_bank_fees')).toBe('6570')
expect(getBASAccount('income_services')).toBe('3001')
})
})
describe('categorizeLineItem', () => {
it('keyword patterns match Swedish terms — dator → expense_equipment', () => {
const result = categorizeLineItem('MacBook Pro dator')
expect(result.category).toBe('expense_equipment')
expect(result.confidence).toBe(0.7)
})
it('matches software patterns', () => {
const result = categorizeLineItem('Adobe Creative Cloud prenumeration')
expect(result.category).toBe('expense_software')
})
it('matches travel patterns', () => {
const result = categorizeLineItem('SJ tåg Stockholm-Malmö')
expect(result.category).toBe('expense_travel')
})
it('returns null category for unrecognized descriptions', () => {
const result = categorizeLineItem('xyzzy foobarbaz')
expect(result.category).toBeNull()
expect(result.confidence).toBe(0)
})
})
describe('processLineItems', () => {
it('prefers AI suggestion over pattern match', () => {
const items: ExtractedLineItem[] = [
{
description: 'MacBook Pro dator', // pattern → equipment
quantity: 1,
unitPrice: 15000,
lineTotal: 15000,
vatRate: 25,
suggestedCategory: 'software', // AI says software
confidence: 0.9,
},
]
const result = processLineItems(items)
expect(result[0].category).toBe('expense_software') // AI wins
expect(result[0].basAccount).toBe('5420')
})
it('falls back to pattern match when no AI suggestion', () => {
const items: ExtractedLineItem[] = [
{
description: 'MacBook Pro dator',
quantity: 1,
unitPrice: 15000,
lineTotal: 15000,
vatRate: 25,
suggestedCategory: null,
},
]
const result = processLineItems(items)
expect(result[0].category).toBe('expense_equipment') // pattern match
expect(result[0].basAccount).toBe('5410')
})
})
describe('calculateReceiptSplit', () => {
it('correct business/private/unclassified totals', () => {
const items = [
{ lineTotal: 100, is_business: true as boolean | null },
{ lineTotal: 50, is_business: false as boolean | null },
{ lineTotal: 25, is_business: null as boolean | null },
]
const result = calculateReceiptSplit(items)
expect(result.businessTotal).toBe(100)
expect(result.privateTotal).toBe(50)
expect(result.unclassifiedTotal).toBe(25)
// 100 / 175 * 100 = 57.142... → 57.1
expect(result.businessPercentage).toBeCloseTo(57.1, 1)
})
it('handles rounding correctly', () => {
const items = [
{ lineTotal: 33.333, is_business: true as boolean | null },
{ lineTotal: 66.667, is_business: false as boolean | null },
]
const result = calculateReceiptSplit(items)
expect(result.businessTotal).toBe(33.33)
expect(result.privateTotal).toBe(66.67)
})
})
describe('getDefaultClassification', () => {
it('Systembolaget defaults to private', () => {
const result = getDefaultClassification(false, true)
expect(result.defaultIsBusiness).toBe(false)
expect(result.requiresReview).toBe(true)
expect(result.warningMessage).toContain('Alkohol')
})
it('restaurant requires review', () => {
const result = getDefaultClassification(true, false)
expect(result.defaultIsBusiness).toBeNull()
expect(result.requiresReview).toBe(true)
expect(result.warningMessage).toContain('Restaurangbesök')
})
it('normal receipt defaults to business', () => {
const result = getDefaultClassification(false, false)
expect(result.defaultIsBusiness).toBe(true)
expect(result.requiresReview).toBe(false)
expect(result.warningMessage).toBeNull()
})
})
@@ -1,286 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
findTransactionMatches,
autoMatchReceipts,
filterUnmatchedTransactions,
filterUnmatchedReceipts,
} from '../receipt-matcher'
import { makeReceipt, makeTransaction } from '@/tests/helpers'
describe('findTransactionMatches', () => {
it('exact date + exact amount → high confidence', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 299,
merchant_name: 'ICA Maxi',
})
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -299,
merchant_name: 'ICA Maxi',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(1)
expect(matches[0].confidence).toBeGreaterThanOrEqual(0.8)
expect(matches[0].dateVariance).toBe(0)
})
it('date within ±3 days → matches with lower confidence', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: '',
})
const transactions = [
makeTransaction({
date: '2024-06-17',
amount: -500,
merchant_name: '',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches.length).toBeGreaterThanOrEqual(1)
expect(matches[0].dateVariance).toBeCloseTo(2, 0)
})
it('date outside ±3 days → no match', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: '',
})
const transactions = [
makeTransaction({
date: '2024-06-25',
amount: -500,
merchant_name: '',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(0)
})
it('amount within 5% tolerance → matches', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 1000,
merchant_name: '',
})
// 4% off = 960
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -960,
merchant_name: '',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches.length).toBeGreaterThanOrEqual(1)
})
it('amount outside tolerance → no match', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 1000,
merchant_name: '',
is_foreign_merchant: false,
})
// 10% off = 900 (way above 5%)
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -900,
merchant_name: '',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(0)
})
it('merchant name similarity boosts confidence', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: 'Coop Konsum',
})
const txWithMerchant = makeTransaction({
id: 'tx-with',
date: '2024-06-15',
amount: -500,
merchant_name: 'Coop Konsum Stockholm',
receipt_id: null,
})
const txWithout = makeTransaction({
id: 'tx-without',
date: '2024-06-15',
amount: -500,
merchant_name: '',
receipt_id: null,
})
const matchesWithMerchant = findTransactionMatches(receipt, [txWithMerchant])
const matchesWithout = findTransactionMatches(receipt, [txWithout])
// Both should match since date+amount are exact
expect(matchesWithMerchant.length).toBeGreaterThanOrEqual(1)
expect(matchesWithout.length).toBeGreaterThanOrEqual(1)
// Merchant match should have higher confidence
expect(matchesWithMerchant[0].confidence).toBeGreaterThan(
matchesWithout[0].confidence
)
})
it('skips already-matched transactions (receipt_id set)', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 299,
})
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -299,
receipt_id: 'already-matched',
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(0)
})
it('skips income transactions (amount >= 0)', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 299,
})
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: 299, // income, positive
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(0)
})
})
describe('autoMatchReceipts', () => {
it('returns matches above threshold', () => {
const receipts = [
makeReceipt({
id: 'r1',
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: 'Coop',
matched_transaction_id: null,
}),
]
const transactions = [
makeTransaction({
id: 'tx1',
date: '2024-06-15',
amount: -500,
merchant_name: 'Coop',
receipt_id: null,
}),
]
const results = autoMatchReceipts(receipts, transactions, 0.5)
expect(results).toHaveLength(1)
expect(results[0].receipt.id).toBe('r1')
expect(results[0].match.confidence).toBeGreaterThanOrEqual(0.5)
})
it('respects custom threshold', () => {
const receipts = [
makeReceipt({
id: 'r1',
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: '',
matched_transaction_id: null,
}),
]
const transactions = [
makeTransaction({
id: 'tx1',
date: '2024-06-17', // 2 days off, no merchant → moderate confidence
amount: -500,
merchant_name: '',
receipt_id: null,
}),
]
// With a very high threshold, it should not match
const highThreshold = autoMatchReceipts(receipts, transactions, 0.99)
expect(highThreshold).toHaveLength(0)
// With a lower threshold, it should match
const lowThreshold = autoMatchReceipts(receipts, transactions, 0.4)
expect(lowThreshold).toHaveLength(1)
})
it('skips already-matched receipts', () => {
const receipts = [
makeReceipt({
id: 'r1',
receipt_date: '2024-06-15',
total_amount: 500,
matched_transaction_id: 'existing-tx',
}),
]
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -500,
receipt_id: null,
}),
]
const results = autoMatchReceipts(receipts, transactions)
expect(results).toHaveLength(0)
})
})
describe('filterUnmatchedTransactions', () => {
it('returns only unmatched expenses', () => {
const transactions = [
makeTransaction({ id: 't1', receipt_id: null, amount: -100 }),
makeTransaction({ id: 't2', receipt_id: 'r1', amount: -200 }), // matched
makeTransaction({ id: 't3', receipt_id: null, amount: 300 }), // income
]
const result = filterUnmatchedTransactions(transactions)
expect(result).toHaveLength(1)
expect(result[0].id).toBe('t1')
})
})
describe('filterUnmatchedReceipts', () => {
it('returns only confirmed unmatched receipts', () => {
const receipts = [
makeReceipt({ id: 'r1', status: 'confirmed', matched_transaction_id: null }),
makeReceipt({ id: 'r2', status: 'confirmed', matched_transaction_id: 'tx1' }),
makeReceipt({ id: 'r3', status: 'extracted', matched_transaction_id: null }),
]
const result = filterUnmatchedReceipts(receipts)
expect(result).toHaveLength(1)
expect(result[0].id).toBe('r1')
})
})
@@ -1,184 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock receipt analyzer
vi.mock('../receipt-analyzer', () => ({
analyzeReceipt: vi.fn(),
}))
// Mock receipt categorizer
vi.mock('../receipt-categorizer', () => ({
processLineItems: vi.fn(),
getDefaultClassification: vi.fn().mockReturnValue({
defaultIsBusiness: true,
requiresReview: false,
warningMessage: null,
}),
}))
// Mock receipt matcher
vi.mock('../receipt-matcher', () => ({
autoMatchReceipts: vi.fn(),
}))
// Mock event bus
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn(), clear: vi.fn() },
}))
import { processReceiptFromDocument } from '../receipt-pipeline'
import { analyzeReceipt } from '../receipt-analyzer'
import { processLineItems } from '../receipt-categorizer'
import { autoMatchReceipts } from '../receipt-matcher'
function createMockSupabase() {
const mockResult = { data: null, error: null }
const chain = {
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockImplementation(() => Promise.resolve(mockResult)),
eq: vi.fn().mockReturnThis(),
is: vi.fn().mockReturnThis(),
lt: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
}
const supabase = {
from: vi.fn().mockReturnValue(chain),
}
return { supabase, chain, setResult: (data: unknown, error: unknown = null) => {
mockResult.data = data as null
mockResult.error = error as null
} }
}
const mockExtraction = {
merchant: {
name: 'ICA Maxi',
orgNumber: '556123-4567',
vatNumber: null,
isForeign: false,
},
receipt: {
date: '2024-06-15',
time: '14:30',
currency: 'SEK',
},
lineItems: [
{ description: 'Mjölk', quantity: 1, unitPrice: 15, lineTotal: 15, vatRate: 12, suggestedCategory: 'other' },
],
totals: { subtotal: 13.39, vatAmount: 1.61, total: 15 },
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
confidence: 0.92,
}
describe('processReceiptFromDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
vi.mocked(analyzeReceipt).mockResolvedValue(mockExtraction)
vi.mocked(processLineItems).mockReturnValue([
{ ...mockExtraction.lineItems[0], category: 'expense_other' as const, basAccount: '6991', confidence: 0.8 },
])
vi.mocked(autoMatchReceipts).mockReturnValue([])
})
it('creates receipt record with extracted data', async () => {
const { supabase, chain, setResult } = createMockSupabase()
const receipt = {
id: 'receipt-1',
user_id: 'user-1',
status: 'extracted',
merchant_name: 'ICA Maxi',
total_amount: 15,
receipt_date: '2024-06-15',
}
// First from() = receipts insert
setResult(receipt)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await processReceiptFromDocument(supabase as any, 'user-1', 'company-1', 'base64data', 'image/jpeg', {
documentId: 'doc-1',
source: 'email',
emailFrom: 'sender@example.com',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(result.receipt.id).toBe('receipt-1')
expect(analyzeReceipt).toHaveBeenCalledWith('base64data', 'image/jpeg')
expect(processLineItems).toHaveBeenCalledWith(mockExtraction.lineItems)
// Verify receipt insert includes source and email_from
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({
source: 'email',
email_from: 'sender@example.com',
document_id: 'doc-1',
})
)
})
it('emits receipt.extracted event', async () => {
const { supabase, setResult } = createMockSupabase()
const receipt = { id: 'receipt-1', user_id: 'user-1' }
setResult(receipt)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await processReceiptFromDocument(supabase as any, 'user-1', 'company-1', 'base64data', 'image/jpeg', {
documentId: 'doc-1',
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(eventBus.emit).toHaveBeenCalledWith({
type: 'receipt.extracted',
payload: expect.objectContaining({
receipt,
documentId: 'doc-1',
confidence: 0.92,
userId: 'user-1',
companyId: 'company-1',
}),
})
})
it('throws when receipt insert fails', async () => {
const { supabase, setResult } = createMockSupabase()
setResult(null, { message: 'insert failed' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await expect(processReceiptFromDocument(supabase as any, 'user-1', 'company-1', 'base64data', 'image/jpeg', {
documentId: null,
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})).rejects.toThrow('Failed to create receipt')
})
it('sets email_from to null when not provided', async () => {
const { supabase, chain, setResult } = createMockSupabase()
setResult({ id: 'receipt-1', user_id: 'user-1' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await processReceiptFromDocument(supabase as any, 'user-1', 'company-1', 'base64data', 'image/jpeg', {
documentId: null,
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({
source: 'upload',
email_from: null,
})
)
})
})
@@ -1,95 +0,0 @@
/**
* Receipt Analyzer — delegates to lib/ai/document-analyzer for extraction,
* then applies receipt-specific validation and enhancement.
*
* SERVER-ONLY: uses the shared vision client via document-analyzer.
*/
import 'server-only'
import type { ReceiptExtractionResult } from '@/types'
import { extractReceipt } from '@/lib/ai/document-analyzer'
import {
SYSTEMBOLAGET_PATTERNS,
RESTAURANT_PATTERNS,
} from './receipt-utils'
// Re-export client-safe functions for convenience
export {
calculateRepresentationLimits,
isRestaurantMCC,
detectSystembolaget,
detectRestaurant,
} from './receipt-utils'
/**
* Analyze a receipt image using Claude Haiku Vision.
* Delegates extraction to the shared core, then applies receipt-specific enhancements.
*/
export async function analyzeReceipt(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise<ReceiptExtractionResult> {
const raw = await extractReceipt(imageBase64, mimeType)
return validateAndEnhanceResult(raw)
}
/**
* Receipt-specific validation and enhancement.
* Applies Systembolaget/restaurant/foreign merchant detection
* on top of the core extraction result.
*/
function validateAndEnhanceResult(result: ReceiptExtractionResult): ReceiptExtractionResult {
// Detect special merchants using local patterns
const merchantName = (result.merchant.name || '').toLowerCase()
const isSystembolaget = result.flags.isSystembolaget || detectSystembolagetLocal(merchantName)
const isRestaurant = result.flags.isRestaurant || detectRestaurantLocal(merchantName)
const isForeign = result.flags.isForeignMerchant || detectForeignMerchant(result.merchant, result.receipt.currency)
return {
...result,
merchant: {
...result.merchant,
isForeign,
},
flags: {
isRestaurant,
isSystembolaget,
isForeignMerchant: isForeign,
},
}
}
/**
* Detect if merchant is Systembolaget (local version using imported patterns)
*/
function detectSystembolagetLocal(merchantName: string): boolean {
return SYSTEMBOLAGET_PATTERNS.some((pattern) => merchantName.includes(pattern))
}
/**
* Detect if merchant is a restaurant (local version using imported patterns)
*/
function detectRestaurantLocal(merchantName: string): boolean {
return RESTAURANT_PATTERNS.some((pattern) => merchantName.includes(pattern))
}
/**
* Detect if merchant is foreign (non-Swedish)
*/
function detectForeignMerchant(
merchant: ReceiptExtractionResult['merchant'],
currency?: string
): boolean {
if (currency && currency !== 'SEK') return true
const orgNumber = merchant.orgNumber || ''
if (orgNumber) {
const digits = orgNumber.replace(/\D/g, '')
if (digits.length !== 10) return true
}
const vatNumber = merchant.vatNumber || ''
if (vatNumber && !vatNumber.toUpperCase().startsWith('SE')) return true
return false
}
@@ -1,322 +0,0 @@
/**
* Receipt Categorizer - Auto-categorization of receipt line items
*
* Maps line item descriptions to BAS accounts and expense categories
* using pattern matching and AI-suggested categories.
*/
import type { TransactionCategory, ExtractedLineItem } from '@/types'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
// Category mappings from suggested category to TransactionCategory
const CATEGORY_MAPPING: Record<string, TransactionCategory> = {
equipment: 'expense_equipment',
software: 'expense_software',
travel: 'expense_travel',
office: 'expense_office',
marketing: 'expense_marketing',
professional_services: 'expense_professional_services',
education: 'expense_education',
other: 'expense_other',
}
// BAS account mappings for expense categories
const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
expense_equipment: '5410', // Förbrukningsinventarier
expense_software: '5420', // Programvaror
expense_travel: '5800', // Resekostnader
expense_office: '5010', // Lokalhyra / Kontorsmaterial
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_education: '6991', // Övriga avdragsgilla kostnader
expense_representation: '6071', // Representation
expense_consumables: '5460', // Förbrukningsvaror
expense_vehicle: '5611', // Drivmedel bil
expense_telecom: '6200', // Telefon och internet
expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
expense_other: '6991', // Övriga avdragsgilla kostnader
// Non-expense categories (for completeness)
income_services: '3001',
income_products: '3001',
income_other: '3900',
private: '2013',
uncategorized: '6991',
}
// Keyword patterns for auto-categorization
const CATEGORY_PATTERNS: Array<{
category: TransactionCategory
patterns: RegExp[]
}> = [
{
category: 'expense_equipment',
patterns: [
/\b(dator|laptop|macbook|ipad|tablet|telefon|iphone|samsung|kamera|camera|mikrofon|microphone|belysning|lighting|stativ|tripod|usb|kabel|adapter|hörlurar|headphones|airpods)\b/i,
/\b(apple|logitech|sony|canon|nikon|rode|elgato|razer)\b/i,
],
},
{
category: 'expense_software',
patterns: [
/\b(adobe|microsoft|spotify|netflix|youtube premium|dropbox|google|icloud|canva|notion|slack|zoom|teams|subscription|prenumeration|licens|license)\b/i,
/\b(app|program|software|saas|cloud)\b/i,
],
},
{
category: 'expense_travel',
patterns: [
/\b(flyg|flight|hotell|hotel|taxi|uber|bolt|tåg|train|sj|arlanda|bromma|landvetter|bensin|fuel|parkering|parking|biljett|ticket)\b/i,
/\b(resa|travel|transport|resekostnad)\b/i,
],
},
{
category: 'expense_office',
patterns: [
/\b(hyra|rent|el|electricity|internet|bredband|vatten|försäkring|insurance|städning|cleaning|kontorsmaterial|papper|toner|skrivare|printer)\b/i,
/\b(ikea|clas ohlson|biltema|staples|lyreco)\b/i,
],
},
{
category: 'expense_marketing',
patterns: [
/\b(reklam|advertising|annons|ad|meta ads|facebook ads|instagram ads|google ads|tiktok ads|influencer|sponsor|kampanj|campaign|pr|press)\b/i,
/\b(marknadsföring|marketing|promotion)\b/i,
],
},
{
category: 'expense_professional_services',
patterns: [
/\b(konsult|consultant|redovisning|accounting|bokföring|bookkeeping|juridik|legal|advokat|lawyer|revisor|auditor)\b/i,
/\b(tjänst|service|arvode|fee)\b/i,
],
},
{
category: 'expense_education',
patterns: [
/\b(kurs|course|utbildning|training|bok|book|seminar|konferens|conference|workshop|webinar|certifiering|certification)\b/i,
/\b(lärande|learning|studie|study)\b/i,
],
},
]
/**
* Get the TransactionCategory from AI-suggested category
*/
export function mapSuggestedCategory(suggestedCategory: string | null): TransactionCategory | null {
if (!suggestedCategory) return null
return CATEGORY_MAPPING[suggestedCategory] || null
}
/**
* Map a booking template ID to a TransactionCategory.
* Falls back to the template's `fallback_category` field.
*/
export function mapTemplateIdToCategory(templateId: string | null | undefined): TransactionCategory | null {
if (!templateId) return null
const template = getTemplateById(templateId)
return template?.fallback_category ?? null
}
/**
* Get BAS account for a category
*/
export function getBASAccount(category: TransactionCategory): string {
return BAS_ACCOUNT_MAPPING[category] || '6991'
}
/**
* Auto-categorize a line item based on description
*/
export function categorizeLineItem(description: string): {
category: TransactionCategory | null
confidence: number
} {
const normalizedDescription = description.toLowerCase()
for (const { category, patterns } of CATEGORY_PATTERNS) {
for (const pattern of patterns) {
if (pattern.test(normalizedDescription)) {
return {
category,
confidence: 0.7, // Pattern-based confidence
}
}
}
}
return {
category: null,
confidence: 0,
}
}
/**
* Process extracted line items and add category suggestions
*/
export function processLineItems(
lineItems: ExtractedLineItem[]
): Array<ExtractedLineItem & { category: TransactionCategory | null; basAccount: string | null }> {
return lineItems.map((item) => {
// First try the AI-suggested template ID
let category = mapTemplateIdToCategory(item.suggestedTemplateId)
let confidence = category ? (item.confidence || 0.85) : 0
// Then try the AI-suggested category
if (!category) {
category = mapSuggestedCategory(item.suggestedCategory)
confidence = item.confidence || 0.8
}
// If no AI suggestion, try pattern matching
if (!category) {
const patternResult = categorizeLineItem(item.description)
category = patternResult.category
confidence = patternResult.confidence
}
return {
...item,
category,
basAccount: category ? getBASAccount(category) : null,
confidence,
}
})
}
/**
* Calculate split amounts for mixed business/private receipt
*/
export function calculateReceiptSplit(
lineItems: Array<{ lineTotal: number; is_business: boolean | null }>
): {
businessTotal: number
privateTotal: number
unclassifiedTotal: number
businessPercentage: number
} {
let businessTotal = 0
let privateTotal = 0
let unclassifiedTotal = 0
for (const item of lineItems) {
if (item.is_business === true) {
businessTotal += item.lineTotal
} else if (item.is_business === false) {
privateTotal += item.lineTotal
} else {
unclassifiedTotal += item.lineTotal
}
}
const total = businessTotal + privateTotal + unclassifiedTotal
const businessPercentage = total > 0 ? (businessTotal / total) * 100 : 0
return {
businessTotal: Math.round(businessTotal * 100) / 100,
privateTotal: Math.round(privateTotal * 100) / 100,
unclassifiedTotal: Math.round(unclassifiedTotal * 100) / 100,
businessPercentage: Math.round(businessPercentage * 10) / 10,
}
}
/**
* Aggregate line items by category for summary display
*/
export function aggregateByCategory(
lineItems: Array<{ lineTotal: number; category: TransactionCategory | null; is_business: boolean | null }>
): Array<{ category: TransactionCategory | null; total: number; count: number }> {
const aggregation = new Map<TransactionCategory | null, { total: number; count: number }>()
for (const item of lineItems) {
// Only aggregate business items
if (item.is_business !== true) continue
const existing = aggregation.get(item.category) || { total: 0, count: 0 }
aggregation.set(item.category, {
total: existing.total + item.lineTotal,
count: existing.count + 1,
})
}
return Array.from(aggregation.entries())
.map(([category, data]) => ({
category,
total: Math.round(data.total * 100) / 100,
count: data.count,
}))
.sort((a, b) => b.total - a.total)
}
/**
* Generate default line item classifications based on receipt flags
*/
export function getDefaultClassification(
isRestaurant: boolean,
isSystembolaget: boolean
): {
defaultIsBusiness: boolean | null
requiresReview: boolean
warningMessage: string | null
} {
if (isSystembolaget) {
return {
defaultIsBusiness: false, // Default to private for Systembolaget
requiresReview: true,
warningMessage: 'Alkohol från Systembolaget är normalt en privat utgift. Om det är representation, ange syfte och antal personer.',
}
}
if (isRestaurant) {
return {
defaultIsBusiness: null, // Need user input
requiresReview: true,
warningMessage: 'Restaurangbesök kräver uppgift om antal personer och syfte för att räknas som representation.',
}
}
return {
defaultIsBusiness: true, // Default to business for normal receipts
requiresReview: false,
warningMessage: null,
}
}
/**
* Category labels in Swedish
*/
export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
expense_equipment: 'Utrustning',
expense_software: 'Programvara',
expense_travel: 'Resa',
expense_office: 'Kontor',
expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänster',
expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Material',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling',
expense_other: 'Övrigt',
income_services: 'Tjänsteintäkt',
income_products: 'Varuintäkt',
income_other: 'Övrig intäkt',
private: 'Privat',
uncategorized: 'Ej bokförd',
}
/**
* Get expense categories only (for dropdown)
*/
export function getExpenseCategories(): Array<{ value: TransactionCategory; label: string }> {
return Object.entries(CATEGORY_LABELS)
.filter(([key]) => key.startsWith('expense_'))
.map(([value, label]) => ({
value: value as TransactionCategory,
label,
}))
}
@@ -1,186 +0,0 @@
/**
* Receipt Matcher - Fuzzy matching between receipts and transactions
*
* Uses date variance, amount tolerance, and merchant name similarity
* to find potential transaction matches for receipts.
*
* Core matching utilities (levenshtein, merchant similarity) are imported
* from @/lib/documents/core-receipt-matcher and re-exported for backward compat.
*/
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
import {
calculateMerchantSimilarity,
calculateMatchConfidence,
levenshteinDistance,
normalizeMerchantName,
DATE_TOLERANCE_DAYS,
AMOUNT_TOLERANCE_PERCENT,
MIN_MATCH_CONFIDENCE,
} from '@/lib/documents/core-receipt-matcher'
// Re-export core functions for backward compatibility
export { calculateMerchantSimilarity, levenshteinDistance, normalizeMerchantName }
/**
* Find potential transaction matches for a receipt
*/
export function findTransactionMatches(
receipt: Receipt,
transactions: Transaction[]
): ReceiptMatchCandidate[] {
if (!receipt.receipt_date || !receipt.total_amount) {
return []
}
const receiptDate = new Date(receipt.receipt_date)
const receiptAmount = Math.abs(receipt.total_amount)
const merchantName = (receipt.merchant_name || '').toLowerCase()
const candidates: ReceiptMatchCandidate[] = []
for (const transaction of transactions) {
// Skip already matched transactions
if (transaction.receipt_id) continue
// Only match expense transactions (negative amounts)
if (transaction.amount >= 0) continue
const transactionDate = new Date(transaction.date)
const transactionAmount = Math.abs(transaction.amount)
// Calculate date variance
const dateVariance = Math.abs(
(receiptDate.getTime() - transactionDate.getTime()) / (1000 * 60 * 60 * 24)
)
// Skip if outside date tolerance
if (dateVariance > DATE_TOLERANCE_DAYS) continue
// Calculate amount variance
const amountVariance = Math.abs(receiptAmount - transactionAmount) / receiptAmount
// Skip if outside amount tolerance (unless it's a foreign receipt with currency conversion)
if (amountVariance > AMOUNT_TOLERANCE_PERCENT && !receipt.is_foreign_merchant) continue
if (amountVariance > 0.15) continue // Hard limit even for foreign
// Calculate merchant similarity
const transactionMerchant = (transaction.merchant_name || transaction.description || '').toLowerCase()
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
// Calculate overall confidence
const { confidence, matchReasons } = calculateReceiptMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity,
receipt,
transaction
)
if (confidence >= MIN_MATCH_CONFIDENCE) {
candidates.push({
transaction,
confidence,
matchReasons,
dateVariance,
amountVariance,
})
}
}
// Sort by confidence (highest first)
return candidates.sort((a, b) => b.confidence - a.confidence)
}
/**
* Wrapper around core calculateMatchConfidence that adds receipt-specific
* MCC bonus logic for backward compatibility.
*/
function calculateReceiptMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
receipt: Receipt,
transaction: Transaction
): { confidence: number; matchReasons: string[] } {
const result = calculateMatchConfidence(dateVariance, amountVariance, merchantSimilarity)
// Bonus for MCC match (receipt-specific: restaurant MCC codes)
if (receipt.is_restaurant && transaction.mcc_code) {
const restaurantMCCs = [5812, 5813, 5814]
if (restaurantMCCs.includes(transaction.mcc_code)) {
result.matchReasons.push('Restaurang MCC matchar')
result.confidence = Math.round((result.confidence + 0.1) * 100) / 100
}
}
return result
}
/**
* Get unmatched transactions for a user
* (transactions without linked receipts)
*/
export function filterUnmatchedTransactions(transactions: Transaction[]): Transaction[] {
return transactions.filter((t) => !t.receipt_id && t.amount < 0)
}
/**
* Get unmatched receipts for a user
* (confirmed receipts without linked transactions)
*/
export function filterUnmatchedReceipts(receipts: Receipt[]): Receipt[] {
return receipts.filter((r) => !r.matched_transaction_id && r.status === 'confirmed')
}
/**
* Auto-match receipts to transactions
* Returns matches with confidence >= threshold
*/
export function autoMatchReceipts(
receipts: Receipt[],
transactions: Transaction[],
confidenceThreshold: number = 0.8
): Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> {
const results: Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> = []
for (const receipt of receipts) {
if (receipt.matched_transaction_id) continue
const matches = findTransactionMatches(receipt, transactions)
const bestMatch = matches[0]
if (bestMatch && bestMatch.confidence >= confidenceThreshold) {
results.push({ receipt, match: bestMatch })
// Mark the transaction as used so it's not matched again
// (Note: this mutates the transaction, caller should be aware)
}
}
return results
}
/**
* Calculate match statistics for queue display
*/
export function calculateMatchStats(
receipts: Receipt[],
transactions: Transaction[]
): {
unmatchedReceiptsCount: number
unmatchedTransactionsCount: number
pendingReviewCount: number
matchedCount: number
} {
const unmatchedReceipts = filterUnmatchedReceipts(receipts)
const unmatchedTransactions = filterUnmatchedTransactions(transactions)
const pendingReview = receipts.filter((r) => r.status === 'extracted')
const matched = receipts.filter((r) => r.matched_transaction_id)
return {
unmatchedReceiptsCount: unmatchedReceipts.length,
unmatchedTransactionsCount: unmatchedTransactions.length,
pendingReviewCount: pendingReview.length,
matchedCount: matched.length,
}
}
@@ -1,172 +0,0 @@
/**
* Receipt Pipeline — shared, reusable receipt processing function.
*
* SERVER-ONLY: uses receipt-analyzer (Anthropic SDK).
*
* Extracts receipt data, categorizes line items, inserts records,
* and attempts auto-matching against bank transactions.
*/
import 'server-only'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Receipt, ReceiptExtractionResult } from '@/types'
import { analyzeReceipt } from './receipt-analyzer'
import { processLineItems, getDefaultClassification } from './receipt-categorizer'
import { autoMatchReceipts } from './receipt-matcher'
import { eventBus } from '@/lib/events/bus'
export interface ReceiptPipelineOptions {
documentId: string | null
source: 'upload' | 'camera' | 'email'
emailFrom?: string
storageUrl: string
preExtracted?: ReceiptExtractionResult
}
export interface ProcessedReceipt {
receipt: Receipt
lineItems: unknown[]
matchedTransaction?: { transactionId: string; confidence: number }
}
/**
* Process a document through the receipt pipeline:
* 1. Analyze with Claude Vision
* 2. Categorize line items
* 3. Insert receipt + line items
* 4. Auto-match against unmatched transactions
* 5. Emit receipt.extracted event
*/
export async function processReceiptFromDocument(
supabase: SupabaseClient,
userId: string,
companyId: string,
base64: string,
mimeType: string,
opts: ReceiptPipelineOptions
): Promise<ProcessedReceipt> {
// 1. Use pre-extracted data if available, otherwise analyze with Claude Vision
const validImageType = mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const extraction = opts.preExtracted ?? await analyzeReceipt(base64, validImageType)
// 2. Categorize line items and apply default business classification
const processedLineItems = processLineItems(extraction.lineItems)
const { defaultIsBusiness } = getDefaultClassification(
extraction.flags.isRestaurant,
extraction.flags.isSystembolaget
)
// 3. Insert receipt record
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: opts.storageUrl,
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,
document_id: opts.documentId,
source: opts.source,
email_from: opts.emailFrom ?? null,
})
.select()
.single()
if (insertError || !receipt) {
throw new Error(`Failed to create receipt: ${insertError?.message}`)
}
// 4. 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
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
: null,
extraction_confidence: item.confidence,
suggested_category: item.suggestedCategory,
category: item.category,
bas_account: item.basAccount,
is_business: defaultIsBusiness,
sort_order: index,
}))
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
}
// 5. Auto-match against unmatched expense transactions (±7 days from receipt date)
let matchedTransaction: ProcessedReceipt['matchedTransaction'] | undefined
if (extraction.receipt.date && extraction.totals.total) {
const receiptDate = new Date(extraction.receipt.date)
const dateFrom = new Date(receiptDate)
dateFrom.setDate(dateFrom.getDate() - 7)
const dateTo = new Date(receiptDate)
dateTo.setDate(dateTo.getDate() + 7)
const { data: transactions } = await supabase
.from('transactions')
.select('*')
.eq('company_id', userId)
.is('receipt_id', null)
.lt('amount', 0)
.gte('date', dateFrom.toISOString().split('T')[0])
.lte('date', dateTo.toISOString().split('T')[0])
if (transactions && transactions.length > 0) {
const matches = autoMatchReceipts([receipt], transactions, 0.8)
if (matches.length > 0) {
const best = matches[0]
matchedTransaction = {
transactionId: best.match.transaction.id,
confidence: best.match.confidence,
}
// Link receipt to transaction
await supabase
.from('receipts')
.update({
matched_transaction_id: best.match.transaction.id,
match_confidence: best.match.confidence,
})
.eq('id', receipt.id)
await supabase
.from('transactions')
.update({ receipt_id: receipt.id })
.eq('id', best.match.transaction.id)
}
}
}
// 6. Emit event
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt,
documentId: opts.documentId,
confidence: extraction.confidence,
userId,
companyId,
},
})
return { receipt, lineItems: processedLineItems, matchedTransaction }
}
@@ -1,94 +0,0 @@
/**
* Receipt utility functions (client-safe)
*
* These functions can be used in both client and server components.
*/
// Known Swedish merchants for special handling
export const SYSTEMBOLAGET_PATTERNS = [
'systembolaget',
'systemet',
'bolaget',
]
export const RESTAURANT_MCC_CODES = [5812, 5813, 5814]
export const RESTAURANT_PATTERNS = [
'restaurang',
'restaurant',
'café',
'cafe',
'bistro',
'pizzeria',
'sushi',
'thai',
'wok',
'grill',
'bar',
'pub',
'krog',
'brasserie',
'trattoria',
'osteria',
'matsal',
'lunch',
'middag',
]
/**
* Check if a transaction MCC code indicates restaurant
*/
export function isRestaurantMCC(mccCode: number | null): boolean {
if (!mccCode) return false
return RESTAURANT_MCC_CODES.includes(mccCode)
}
/**
* Calculate restaurant representation limits
*
* Swedish rules (2024):
* - Max 60 kr/person för intern representation (lunch/middag)
* - Max 180 kr/person för extern representation
* - Max 180 kr/person för representationsgåvor
*
* For simplicity, we use 60 kr as the safe deductible limit per person
*/
export function calculateRepresentationLimits(
totalAmount: number,
persons: number
): {
deductibleAmount: number
nonDeductibleAmount: number
perPersonAmount: number
maxDeductiblePerPerson: number
} {
const MAX_DEDUCTIBLE_PER_PERSON = 60
const perPersonAmount = totalAmount / persons
const deductiblePerPerson = Math.min(perPersonAmount, MAX_DEDUCTIBLE_PER_PERSON)
const deductibleAmount = deductiblePerPerson * persons
const nonDeductibleAmount = totalAmount - deductibleAmount
return {
deductibleAmount: Math.round(deductibleAmount * 100) / 100,
nonDeductibleAmount: Math.round(nonDeductibleAmount * 100) / 100,
perPersonAmount: Math.round(perPersonAmount * 100) / 100,
maxDeductiblePerPerson: MAX_DEDUCTIBLE_PER_PERSON,
}
}
/**
* Detect if merchant is Systembolaget
*/
export function detectSystembolaget(merchantName: string): boolean {
const name = merchantName.toLowerCase()
return SYSTEMBOLAGET_PATTERNS.some((pattern) => name.includes(pattern))
}
/**
* Detect if merchant is a restaurant
*/
export function detectRestaurant(merchantName: string): boolean {
const name = merchantName.toLowerCase()
return RESTAURANT_PATTERNS.some((pattern) => name.includes(pattern))
}
@@ -1,25 +0,0 @@
{
"id": "receipt-ocr",
"sector": "general",
"exportName": "receiptOcrExtension",
"entryPoint": "@/extensions/general/receipt-ocr",
"workspace": "@/components/extensions/general/ReceiptOcrWorkspace",
"requiredEnvVars": ["ANTHROPIC_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["@anthropic-ai/sdk"],
"definition": {
"name": "Kvittoscanning",
"category": "import",
"icon": "Camera",
"dataPattern": "manual",
"hasOwnData": true,
"description": "Skanna kvitton och extrahera data automatiskt",
"quickAction": {
"label": "Skanna kvitto",
"description": "Fotografera & spara",
"icon": "Camera",
"href": "/receipts/scan"
},
"longDescription": "Ladda upp kvittofoton och låt systemet automatiskt extrahera leverantör, belopp, moms och datum. Sparar tid och minskar manuell inmatning."
}
}
@@ -1,344 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Camera,
Receipt,
Check,
Clock,
AlertCircle,
ArrowRight,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { useRouter } from 'next/navigation'
import ReceiptDashboard from '../components/ReceiptDashboard'
import ReceiptReviewView from '../components/ReceiptReviewView'
import TransactionMatcher from '../components/TransactionMatcher'
import type { Receipt as ReceiptType, ReceiptLineItem, ReceiptQueueSummary, ConfirmLineItemInput } from '@/types'
type ViewMode = 'dashboard' | 'list' | 'review' | 'match'
type ListFilter = 'all' | 'pending' | 'confirmed'
export default function ReceiptsPage() {
const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>('dashboard')
const [listFilter, setListFilter] = useState<ListFilter>('all')
const [receipts, setReceipts] = useState<(ReceiptType & { line_items: ReceiptLineItem[] })[]>([])
const [summary, setSummary] = useState<ReceiptQueueSummary | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Selected receipt for review/match
const [selectedReceipt, setSelectedReceipt] = useState<(ReceiptType & { line_items: ReceiptLineItem[] }) | null>(null)
// Fetch receipts and summary
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const [receiptsRes, queueRes] = await Promise.all([
fetch('/api/extensions/ext/receipt-ocr'),
fetch('/api/extensions/ext/receipt-ocr/queue'),
])
const [receiptsData, queueData] = await Promise.all([
receiptsRes.json(),
queueRes.json(),
])
if (receiptsData.data) {
setReceipts(receiptsData.data)
}
if (queueData.data?.summary) {
setSummary(queueData.data.summary)
}
} catch (error) {
console.error('Fetch error:', error)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Filter receipts based on tab
const filteredReceipts = receipts.filter((r) => {
if (listFilter === 'pending') return r.status === 'extracted'
if (listFilter === 'confirmed') return r.status === 'confirmed'
return true
})
// Handle scan receipt
const handleScanReceipt = () => {
router.push('/receipts/scan')
}
// Handle view receipt queue
const handleViewReceiptQueue = () => {
setListFilter('pending')
setViewMode('list')
}
// Handle view transaction queue
const handleViewTransactionQueue = () => {
router.push('/transactions?filter=unmatched')
}
// Handle receipt selection for review
const handleSelectReceipt = (receipt: ReceiptType & { line_items: ReceiptLineItem[] }) => {
setSelectedReceipt(receipt)
if (receipt.status === 'extracted') {
setViewMode('review')
} else {
setViewMode('match')
}
}
// Handle confirm receipt
const handleConfirmReceipt = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/ext/receipt-ocr/${selectedReceipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match receipt to transaction
const handleMatchReceipt = async (transactionId: string, confidence: number) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/ext/receipt-ocr/${selectedReceipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
}
}
// Render receipt review view
if (viewMode === 'review' && selectedReceipt) {
return (
<ReceiptReviewView
receipt={selectedReceipt}
onConfirm={handleConfirmReceipt}
onCancel={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onFindMatches={() => setViewMode('match')}
/>
)
}
// Render transaction matcher
if (viewMode === 'match' && selectedReceipt) {
return (
<TransactionMatcher
receipt={selectedReceipt}
onMatch={handleMatchReceipt}
onSkip={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onClose={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
/>
)
}
// Render main page
return (
<div className="container max-w-4xl mx-auto p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Kvittohantering</h1>
<Button onClick={handleScanReceipt}>
<Camera className="mr-2 h-4 w-4" />
Skanna kvitto
</Button>
</div>
{/* Dashboard or List view toggle */}
<Tabs
value={viewMode === 'list' ? 'list' : 'dashboard'}
onValueChange={(v) => setViewMode(v as ViewMode)}
>
<TabsList>
<TabsTrigger value="dashboard">Översikt</TabsTrigger>
<TabsTrigger value="list">Alla kvitton</TabsTrigger>
</TabsList>
</Tabs>
{viewMode === 'dashboard' && (
<>
{isLoading ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
) : summary ? (
<ReceiptDashboard
summary={summary}
onScanReceipt={handleScanReceipt}
onViewReceiptQueue={handleViewReceiptQueue}
onViewTransactionQueue={handleViewTransactionQueue}
/>
) : (
<Card>
<CardContent className="pt-4 text-center py-8">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p>Kunde inte ladda data</p>
<Button variant="outline" className="mt-4" onClick={fetchData}>
Försök igen
</Button>
</CardContent>
</Card>
)}
</>
)}
{viewMode === 'list' && (
<>
{/* List filters */}
<div className="flex gap-2">
<Button
variant={listFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('all')}
>
Alla
</Button>
<Button
variant={listFilter === 'pending' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('pending')}
>
<Clock className="mr-1 h-3 w-3" />
Att granska
</Button>
<Button
variant={listFilter === 'confirmed' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('confirmed')}
>
<Check className="mr-1 h-3 w-3" />
Bekräftade
</Button>
</div>
{/* Receipt list */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
) : filteredReceipts.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Receipt className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">Inga kvitton hittades</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredReceipts.map((receipt) => (
<Card
key={receipt.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => handleSelectReceipt(receipt)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{receipt.image_url ? (
<img
src={receipt.image_url}
alt="Receipt"
className="h-16 w-12 object-cover rounded"
/>
) : (
<div className="h-16 w-12 bg-muted rounded flex items-center justify-center">
<Receipt className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div>
<p className="font-medium">{receipt.merchant_name || 'Okänt kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">
{formatDate(receipt.receipt_date)}
</p>
)}
<div className="flex flex-wrap gap-1 mt-1">
<Badge
variant={
receipt.status === 'confirmed'
? 'default'
: receipt.status === 'extracted'
? 'secondary'
: receipt.status === 'error'
? 'destructive'
: 'outline'
}
>
{receipt.status === 'confirmed' && 'Bekräftat'}
{receipt.status === 'extracted' && 'Att granska'}
{receipt.status === 'processing' && 'Analyserar...'}
{receipt.status === 'pending' && 'Väntar'}
{receipt.status === 'error' && 'Fel'}
</Badge>
{receipt.matched_transaction_id && (
<Badge variant="outline">Kopplat</Badge>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
<ArrowRight className="h-4 w-4 text-muted-foreground mt-2 ml-auto" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
</div>
)
}
@@ -1,157 +0,0 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import ReceiptCamera from '../../components/ReceiptCamera'
import ReceiptReviewView from '../../components/ReceiptReviewView'
import TransactionMatcher from '../../components/TransactionMatcher'
import { Loader2 } from 'lucide-react'
import type { Receipt, ReceiptLineItem, ConfirmLineItemInput } from '@/types'
type PageState = 'camera' | 'uploading' | 'review' | 'match' | 'done'
export default function ScanReceiptPage() {
const router = useRouter()
const [pageState, setPageState] = useState<PageState>('camera')
const [uploadError, setUploadError] = useState<string | null>(null)
const [receipt, setReceipt] = useState<(Receipt & { line_items: ReceiptLineItem[] }) | null>(null)
// Handle image capture
const handleCapture = async (imageData: string, mimeType: string) => {
setPageState('uploading')
setUploadError(null)
try {
// Convert base64 to blob
const byteCharacters = atob(imageData)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: mimeType })
// Create form data
const formData = new FormData()
formData.append('image', blob, 'receipt.jpg')
// Upload and analyze
const response = await fetch('/api/extensions/ext/receipt-ocr/upload', {
method: 'POST',
body: formData,
})
const data = await response.json()
if (response.ok && data.data) {
setReceipt(data.data)
setPageState('review')
} else {
setUploadError(data.error || 'Kunde inte analysera kvittot')
setPageState('camera')
}
} catch (error) {
console.error('Upload error:', error)
setUploadError('Nätverksfel. Försök igen.')
setPageState('camera')
}
}
// Handle close/cancel
const handleClose = () => {
router.push('/receipts')
}
// Handle confirm receipt
const handleConfirm = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!receipt) return
const response = await fetch(`/api/extensions/ext/receipt-ocr/${receipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
const updatedData = await response.json()
setReceipt(updatedData.data)
setPageState('match')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match to transaction
const handleMatch = async (transactionId: string, confidence: number) => {
if (!receipt) return
const response = await fetch(`/api/extensions/ext/receipt-ocr/${receipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
router.push('/receipts')
}
}
// Handle skip matching
const handleSkipMatch = () => {
router.push('/receipts')
}
// Render uploading state
if (pageState === 'uploading') {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Analyserar kvitto...</p>
<p className="text-sm text-muted-foreground mt-1">
AI läser av artiklar och belopp
</p>
</div>
)
}
// Render review view
if (pageState === 'review' && receipt) {
return (
<ReceiptReviewView
receipt={receipt}
onConfirm={handleConfirm}
onCancel={handleClose}
onFindMatches={() => setPageState('match')}
/>
)
}
// Render match view
if (pageState === 'match' && receipt) {
return (
<TransactionMatcher
receipt={receipt}
onMatch={handleMatch}
onSkip={handleSkipMatch}
onClose={handleClose}
/>
)
}
// Render camera (default)
return (
<>
{uploadError && (
<div className="fixed top-4 left-4 right-4 z-[60] bg-destructive text-destructive-foreground p-3 rounded-lg text-sm">
{uploadError}
</div>
)}
<ReceiptCamera onCapture={handleCapture} onClose={handleClose} />
</>
)
}
-15
View File
@@ -1,15 +0,0 @@
// Re-export from canonical source — kept for backwards compatibility with extension-internal imports
export type {
ReceiptStatus,
Receipt,
ReceiptLineItem,
ReceiptExtractionResult,
ExtractedLineItem,
ReceiptMatchCandidate,
CreateReceiptInput,
ConfirmReceiptInput,
ConfirmLineItemInput,
ReceiptQueueSummary,
CameraQualityFeedback,
} from '@/types'
export { RECEIPT_STATUS_LABELS } from '@/types'
+1 -1
View File
@@ -33,7 +33,7 @@ export const skatteverketExtension: Extension = {
settingsPanel: {
label: 'Skatteverket',
path: '/settings?tab=skatteverket',
path: '/settings/account',
},
apiRoutes: [