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:
co-authored by
Claude Opus 4.6
parent
e89f2c402d
commit
d0b3f21bde
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -33,7 +33,7 @@ export const skatteverketExtension: Extension = {
|
||||
|
||||
settingsPanel: {
|
||||
label: 'Skatteverket',
|
||||
path: '/settings?tab=skatteverket',
|
||||
path: '/settings/account',
|
||||
},
|
||||
|
||||
apiRoutes: [
|
||||
|
||||
Reference in New Issue
Block a user