feat: merge user-description-match into ai-categorization with AI-powered description analysis
Consolidate the standalone user-description-match extension into ai-categorization, adding an AI description analyzer that provides account/VAT suggestions alongside template matching. The describe transaction dialog now shows AI suggestions with confidence scores and supports both template-based and AI-based booking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
aabe527f68
commit
1a1c6ba40f
@@ -172,14 +172,13 @@ Extensions are opt-in plugins controlled by `extensions.config.json`. Core build
|
||||
| Extension | Category | Env Vars Required |
|
||||
|-----------|----------|-------------------|
|
||||
| `receipt-ocr` | import | `ANTHROPIC_API_KEY` |
|
||||
| `ai-categorization` | operations | `OPENAI_API_KEY` |
|
||||
| `ai-categorization` | operations | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` |
|
||||
| `ai-chat` | operations | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` |
|
||||
| `push-notifications` | operations | VAPID keys |
|
||||
| `invoice-inbox` | import | `ANTHROPIC_API_KEY` |
|
||||
| `calendar` | operations | — |
|
||||
| `enable-banking` | import | Enable Banking keys |
|
||||
| `email` | operations | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` |
|
||||
| `user-description-match` | operations | — |
|
||||
|
||||
### Creating Extensions
|
||||
|
||||
|
||||
@@ -6,9 +6,48 @@ import { DescribeTransactionSchema } from '@/lib/api/schemas'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { DescriptionAnalysisInput, DescriptionAnalysisResult } from '@/extensions/general/ai-categorization/lib/description-analyzer'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
async function getTemplateMatches(
|
||||
aiExt: Extension | undefined,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType,
|
||||
description: string
|
||||
): Promise<TemplateMatch[]> {
|
||||
if (aiExt?.services?.findSimilarTemplates) {
|
||||
return aiExt.services.findSimilarTemplates(transaction, entityType, 10, description)
|
||||
}
|
||||
return findMatchingTemplates(transaction, entityType)
|
||||
}
|
||||
|
||||
async function getAiAnalysis(
|
||||
aiExt: Extension | undefined,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType,
|
||||
description: string
|
||||
): Promise<DescriptionAnalysisResult | null> {
|
||||
if (!aiExt?.services?.analyzeDescription) return null
|
||||
|
||||
try {
|
||||
const input: DescriptionAnalysisInput = {
|
||||
description,
|
||||
transactionAmount: transaction.amount,
|
||||
transactionDate: transaction.date,
|
||||
transactionDescription: transaction.description,
|
||||
merchantName: transaction.merchant_name,
|
||||
currency: transaction.currency,
|
||||
entityType,
|
||||
}
|
||||
return await aiExt.services.analyzeDescription(input)
|
||||
} catch (error) {
|
||||
console.error('[describe] AI analysis failed, continuing with templates only:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -47,23 +86,18 @@ export async function POST(
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Run embedding search with user description dominating the query
|
||||
let templates: TemplateMatch[]
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
if (aiExt?.services?.findSimilarTemplates) {
|
||||
templates = await aiExt.services.findSimilarTemplates(
|
||||
transaction as Transaction,
|
||||
entityType,
|
||||
10,
|
||||
description
|
||||
)
|
||||
} else {
|
||||
// Fallback to keyword matching when AI extension not loaded
|
||||
templates = findMatchingTemplates(transaction as Transaction, entityType)
|
||||
}
|
||||
|
||||
// Flag if top confidence is too low
|
||||
const needsMoreDetail = templates.length === 0 || templates[0].confidence < 0.55
|
||||
// Run template matching and AI analysis in parallel
|
||||
const [templates, aiSuggestion] = await Promise.all([
|
||||
getTemplateMatches(aiExt, transaction as Transaction, entityType, description),
|
||||
getAiAnalysis(aiExt, transaction as Transaction, entityType, description),
|
||||
])
|
||||
|
||||
// AI rescues weak templates: needs_more_detail is false when AI provided a suggestion
|
||||
const needsMoreDetail = aiSuggestion
|
||||
? false
|
||||
: templates.length === 0 || templates[0].confidence < 0.55
|
||||
|
||||
// Count uncategorized sibling transactions from same merchant
|
||||
let batchCandidateCount = 0
|
||||
@@ -97,6 +131,16 @@ export async function POST(
|
||||
special_rules_sv: m.template.special_rules_sv || null,
|
||||
risk_level: m.template.risk_level,
|
||||
})),
|
||||
ai_suggestion: aiSuggestion ? {
|
||||
debit_account: aiSuggestion.debitAccount,
|
||||
credit_account: aiSuggestion.creditAccount,
|
||||
vat_treatment: aiSuggestion.vatTreatment,
|
||||
category: aiSuggestion.category,
|
||||
confidence: aiSuggestion.confidence,
|
||||
reasoning: aiSuggestion.reasoning,
|
||||
warnings: aiSuggestion.warnings,
|
||||
template_id: aiSuggestion.templateId,
|
||||
} : null,
|
||||
needs_more_detail: needsMoreDetail,
|
||||
user_description: description,
|
||||
batch_candidate_count: batchCandidateCount,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { TextSearch } from 'lucide-react'
|
||||
|
||||
export default function UserDescriptionMatchWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Beskrivningsmatchning"
|
||||
description="Beskriv transaktioner med egna ord vid kategorisering. Systemet lär sig automatiskt och applicerar på framtida transaktioner från samma leverantör."
|
||||
icon={<TextSearch className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
@@ -45,8 +46,20 @@ interface TemplateMatch {
|
||||
risk_level: string
|
||||
}
|
||||
|
||||
interface AiSuggestion {
|
||||
debit_account: string
|
||||
credit_account: string
|
||||
vat_treatment: string | null
|
||||
category: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
warnings: string[]
|
||||
template_id: string | null
|
||||
}
|
||||
|
||||
interface DescribeResult {
|
||||
templates: TemplateMatch[]
|
||||
ai_suggestion: AiSuggestion | null
|
||||
needs_more_detail: boolean
|
||||
user_description: string
|
||||
batch_candidate_count: number
|
||||
@@ -62,6 +75,7 @@ interface DescribeTransactionDialogProps {
|
||||
}
|
||||
|
||||
type Step = 'describe' | 'pick' | 'batch'
|
||||
type Selection = { type: 'template'; templateId: string } | { type: 'ai' }
|
||||
|
||||
function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
|
||||
const desc = (transaction.description || '').toLowerCase()
|
||||
@@ -71,7 +85,6 @@ function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
|
||||
return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning']
|
||||
}
|
||||
|
||||
// Contextual suggestions based on description keywords
|
||||
if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) {
|
||||
return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret']
|
||||
}
|
||||
@@ -88,10 +101,18 @@ function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
|
||||
return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial']
|
||||
}
|
||||
|
||||
// Generic expense suggestions
|
||||
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam']
|
||||
}
|
||||
|
||||
function getVatRateFromTreatment(treatment: string | null): number {
|
||||
switch (treatment) {
|
||||
case 'standard_25': return 0.25
|
||||
case 'reduced_12': return 0.12
|
||||
case 'reduced_6': return 0.06
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
|
||||
export default function DescribeTransactionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -106,7 +127,10 @@ export default function DescribeTransactionDialog({
|
||||
const [isBooking, setIsBooking] = useState(false)
|
||||
const [isBatchApplying, setIsBatchApplying] = useState(false)
|
||||
const [describeResult, setDescribeResult] = useState<DescribeResult | null>(null)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||||
const [selection, setSelection] = useState<Selection | null>(null)
|
||||
|
||||
const selectedTemplateId = selection?.type === 'template' ? selection.templateId : null
|
||||
const isAiSelected = selection?.type === 'ai'
|
||||
|
||||
function resetState() {
|
||||
setStep('describe')
|
||||
@@ -115,7 +139,7 @@ export default function DescribeTransactionDialog({
|
||||
setIsBooking(false)
|
||||
setIsBatchApplying(false)
|
||||
setDescribeResult(null)
|
||||
setSelectedTemplateId(null)
|
||||
setSelection(null)
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
@@ -147,7 +171,7 @@ export default function DescribeTransactionDialog({
|
||||
}
|
||||
|
||||
setDescribeResult(result.data)
|
||||
setSelectedTemplateId(null)
|
||||
setSelection(null)
|
||||
setStep('pick')
|
||||
} catch {
|
||||
toast({
|
||||
@@ -160,18 +184,44 @@ export default function DescribeTransactionDialog({
|
||||
}
|
||||
|
||||
async function handleBook() {
|
||||
if (!transaction || !selectedTemplateId || !describeResult) return
|
||||
if (!transaction || !describeResult || !selection) return
|
||||
|
||||
setIsBooking(true)
|
||||
try {
|
||||
// Build categorize request based on selection type
|
||||
let body: Record<string, unknown>
|
||||
|
||||
if (selection.type === 'template') {
|
||||
body = {
|
||||
is_business: true,
|
||||
template_id: selection.templateId,
|
||||
user_description: describeResult.user_description,
|
||||
}
|
||||
} else {
|
||||
// AI suggestion selected
|
||||
const ai = describeResult.ai_suggestion!
|
||||
if (ai.template_id) {
|
||||
// AI matched a template — use template-based booking
|
||||
body = {
|
||||
is_business: true,
|
||||
template_id: ai.template_id,
|
||||
user_description: describeResult.user_description,
|
||||
}
|
||||
} else {
|
||||
// AI category-based booking — category maps to the correct account
|
||||
body = {
|
||||
is_business: true,
|
||||
category: ai.category,
|
||||
vat_treatment: ai.vat_treatment || undefined,
|
||||
user_description: describeResult.user_description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/transactions/${transaction.id}/categorize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
is_business: true,
|
||||
template_id: selectedTemplateId,
|
||||
user_description: describeResult.user_description,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
@@ -204,7 +254,17 @@ export default function DescribeTransactionDialog({
|
||||
}
|
||||
|
||||
async function handleBatchApply() {
|
||||
if (!describeResult || !selectedTemplateId) return
|
||||
if (!describeResult) return
|
||||
|
||||
// For batch apply, we need a template_id
|
||||
let templateId: string | null = null
|
||||
if (selection?.type === 'template') {
|
||||
templateId = selection.templateId
|
||||
} else if (selection?.type === 'ai' && describeResult.ai_suggestion?.template_id) {
|
||||
templateId = describeResult.ai_suggestion.template_id
|
||||
}
|
||||
|
||||
if (!templateId) return
|
||||
|
||||
setIsBatchApplying(true)
|
||||
try {
|
||||
@@ -213,7 +273,7 @@ export default function DescribeTransactionDialog({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
merchant_name: describeResult.merchant_name,
|
||||
template_id: selectedTemplateId,
|
||||
template_id: templateId,
|
||||
is_business: true,
|
||||
user_description: describeResult.user_description,
|
||||
}),
|
||||
@@ -263,6 +323,13 @@ export default function DescribeTransactionDialog({
|
||||
if (!transaction) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
const aiSuggestion = describeResult?.ai_suggestion
|
||||
// Check if AI agrees with top template
|
||||
const topTemplate = describeResult?.templates[0]
|
||||
const aiAgreesWithTop = aiSuggestion && topTemplate && aiSuggestion.debit_account === topTemplate.debit_account
|
||||
|
||||
// Determine if batch apply is available (requires a template_id)
|
||||
const canBatchApply = selection?.type === 'template' || (selection?.type === 'ai' && !!describeResult?.ai_suggestion?.template_id)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isBooking || isBatchApplying) ? undefined : handleOpenChange}>
|
||||
@@ -361,7 +428,72 @@ export default function DescribeTransactionDialog({
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto max-h-[40vh] space-y-2 pr-1">
|
||||
{describeResult.templates.length === 0 ? (
|
||||
{/* AI Suggestion Card */}
|
||||
{aiSuggestion && (
|
||||
<Card
|
||||
className={`cursor-pointer transition-colors hover:border-primary/50 ${
|
||||
isAiSelected ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => setSelection({ type: 'ai' })}
|
||||
>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
|
||||
AI-forslag
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{aiSuggestion.reasoning}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
D: {formatAccountWithName(aiSuggestion.debit_account)}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
K: {formatAccountWithName(aiSuggestion.credit_account)}
|
||||
</Badge>
|
||||
{aiSuggestion.vat_treatment && aiSuggestion.vat_treatment !== 'exempt' && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
Moms {Math.round(getVatRateFromTreatment(aiSuggestion.vat_treatment) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
{aiSuggestion.vat_treatment === 'exempt' && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
Momsfritt
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{aiSuggestion.warnings.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{aiSuggestion.warnings.map((warning, i) => (
|
||||
<Badge key={i} variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
|
||||
{warning}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge
|
||||
variant={aiSuggestion.confidence >= 0.7 ? 'default' : 'outline'}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{Math.round(aiSuggestion.confidence * 100)}%
|
||||
</Badge>
|
||||
{isAiSelected && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Template cards */}
|
||||
{describeResult.templates.length === 0 && !aiSuggestion ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Inga matchande mallar hittades. Forsok med en annan beskrivning.
|
||||
</p>
|
||||
@@ -374,12 +506,19 @@ export default function DescribeTransactionDialog({
|
||||
? 'border-primary bg-primary/5'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => setSelectedTemplateId(template.template_id)}
|
||||
onClick={() => setSelection({ type: 'template', templateId: template.template_id })}
|
||||
>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm">{template.name_sv}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="font-medium text-sm">{template.name_sv}</p>
|
||||
{aiAgreesWithTop && template.template_id === topTemplate.template_id && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
AI bekraftar
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{template.description_sv && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{template.description_sv}
|
||||
@@ -446,19 +585,33 @@ export default function DescribeTransactionDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Journal entry preview for selected template */}
|
||||
{selectedTemplateId && (() => {
|
||||
const tmpl = describeResult.templates.find(t => t.template_id === selectedTemplateId)
|
||||
if (!tmpl) return null
|
||||
return (
|
||||
<JournalEntryPreview
|
||||
amount={transaction.amount}
|
||||
currency={transaction.currency}
|
||||
templateDebitAccount={tmpl.debit_account}
|
||||
templateCreditAccount={tmpl.credit_account}
|
||||
templateVatRate={tmpl.vat_rate}
|
||||
/>
|
||||
)
|
||||
{/* Journal entry preview for selected template or AI suggestion */}
|
||||
{selection && (() => {
|
||||
if (selection.type === 'ai' && aiSuggestion) {
|
||||
return (
|
||||
<JournalEntryPreview
|
||||
amount={transaction.amount}
|
||||
currency={transaction.currency}
|
||||
templateDebitAccount={aiSuggestion.debit_account}
|
||||
templateCreditAccount={aiSuggestion.credit_account}
|
||||
templateVatRate={getVatRateFromTreatment(aiSuggestion.vat_treatment)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (selection.type === 'template') {
|
||||
const tmpl = describeResult.templates.find(t => t.template_id === selection.templateId)
|
||||
if (!tmpl) return null
|
||||
return (
|
||||
<JournalEntryPreview
|
||||
amount={transaction.amount}
|
||||
currency={transaction.currency}
|
||||
templateDebitAccount={tmpl.debit_account}
|
||||
templateCreditAccount={tmpl.credit_account}
|
||||
templateVatRate={tmpl.vat_rate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
@@ -467,7 +620,7 @@ export default function DescribeTransactionDialog({
|
||||
className="flex-shrink-0"
|
||||
onClick={() => {
|
||||
setStep('describe')
|
||||
setSelectedTemplateId(null)
|
||||
setSelection(null)
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
@@ -475,7 +628,7 @@ export default function DescribeTransactionDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!selectedTemplateId || isBooking}
|
||||
disabled={!selection || isBooking}
|
||||
onClick={handleBook}
|
||||
>
|
||||
{isBooking ? (
|
||||
@@ -497,17 +650,19 @@ export default function DescribeTransactionDialog({
|
||||
<p className="text-sm font-medium">Transaktionen ar bokford!</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Det finns ytterligare{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{describeResult.batch_candidate_count}
|
||||
</span>{' '}
|
||||
obokforda transaktioner fran{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{describeResult.merchant_name}
|
||||
</span>
|
||||
. Anvand samma mall?
|
||||
</p>
|
||||
{canBatchApply && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Det finns ytterligare{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{describeResult.batch_candidate_count}
|
||||
</span>{' '}
|
||||
obokforda transaktioner fran{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{describeResult.merchant_name}
|
||||
</span>
|
||||
. Anvand samma mall?
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -516,20 +671,22 @@ export default function DescribeTransactionDialog({
|
||||
onClick={handleSkipBatch}
|
||||
disabled={isBatchApplying}
|
||||
>
|
||||
Nej, bara den har
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleBatchApply}
|
||||
disabled={isBatchApplying}
|
||||
>
|
||||
{isBatchApplying ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{isBatchApplying
|
||||
? 'Bokfor...'
|
||||
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
|
||||
{canBatchApply ? 'Nej, bara den har' : 'Stang'}
|
||||
</Button>
|
||||
{canBatchApply && (
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleBatchApply}
|
||||
disabled={isBatchApplying}
|
||||
>
|
||||
{isBatchApplying ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{isBatchApplying
|
||||
? 'Bokfor...'
|
||||
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -91,8 +91,6 @@
|
||||
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
|
||||
│ calendar │ extensions/general/calendar/ │ (none) │
|
||||
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
|
||||
│ user-description-match │ extensions/general/user-description-match/ │ (none) │
|
||||
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
|
||||
│ eu-sales-list │ extensions/export/eu-sales-list/ │ (none) │
|
||||
├────────────────────────┼────────────────────────────────────────────┼───────────────────────────────────────────────────┤
|
||||
│ vat-monitor │ extensions/export/vat-monitor/ │ (none) │
|
||||
@@ -457,8 +455,6 @@
|
||||
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
|
||||
│ calendar │ (none) │ │
|
||||
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
|
||||
│ user-description-match │ (none) │ │
|
||||
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
|
||||
│ All export extensions │ (none) │ │
|
||||
├────────────────────────┼───────────────────────────────────────────────────┼────────────────────────┤
|
||||
│ All sector extensions │ (none) │ │
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"$schema":"./extensions.schema.json","extensions":["receipt-ocr","ai-categorization","ai-chat","push-notifications","invoice-inbox","calendar","enable-banking","email","user-description-match"]}
|
||||
{"$schema":"./extensions.schema.json","extensions":["receipt-ocr","ai-categorization","ai-chat","push-notifications","invoice-inbox","calendar","enable-banking","email"]}
|
||||
|
||||
@@ -64,7 +64,6 @@ extensions/
|
||||
invoice-inbox/
|
||||
calendar/
|
||||
email/
|
||||
user-description-match/
|
||||
restaurant/ ← Restaurant sector extensions
|
||||
food-cost/
|
||||
earnings-per-liter/
|
||||
@@ -334,9 +333,6 @@ extensions/ ← Extension source code (opt-in via conf
|
||||
calendar/
|
||||
manifest.json
|
||||
index.ts
|
||||
user-description-match/
|
||||
manifest.json
|
||||
index.ts
|
||||
restaurant/ ← Restaurant sector
|
||||
food-cost/
|
||||
manifest.json
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
"invoice-inbox",
|
||||
"calendar",
|
||||
"enable-banking",
|
||||
"email",
|
||||
"user-description-match"
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
|
||||
|
||||
@@ -387,6 +387,12 @@ export const aiCategorizationExtension: Extension = {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
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 bokfors som tjansteintakt.',
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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 ar expert pa svensk bokforing enligt BAS-kontoplanen. Analysera anvandarens beskrivning av en banktransaktion och returnera ett bokforingsforslag.
|
||||
|
||||
VANLIGA BAS-KONTON:
|
||||
Utgifter: 5010 Lokalhyra | 5410 Forbrukningsinventarier | 5420 Programvara | 5460 Forbrukningsvaror | 5611 Bil/drivmedel | 5800 Resekostnader | 5910 Annonsering | 6071 Representation mat | 6200 Telefon/internet | 6530 Redovisning/konsult | 6570 Bankavgifter | 6991 Ovriga kostnader | ${entityType === 'aktiebolag' ? '7610' : '6991'} Utbildning
|
||||
Intakter: 3001 Forsaljning 25% | 3002 Forsaljning 12% | 3003 Forsaljning 6% | 3305 Export | 3308 EU-tjanster | 3900 Ovriga intakter
|
||||
Moms: 2611 Utg moms 25% | 2621 Utg moms 12% | 2631 Utg moms 6% | 2641 Ing moms | 2645 Beraknad ing moms
|
||||
Ovrigt: 1510 Kundfordringar | 1930 Foretagskonto | 2440 Leverantorsskulder | ${privateAccount} Privat
|
||||
|
||||
MOMSREGLER:
|
||||
- standard_25: Normala varor/tjanster (25%)
|
||||
- reduced_12: Livsmedel, hotell, konstverk (12%)
|
||||
- reduced_6: Bocker, tidningar, kollektivtrafik, kultur (6%)
|
||||
- reverse_charge: Tjanstekop fran utlandet/EU
|
||||
- export: Forsaljning utanfor Sverige
|
||||
- exempt: Momsfritt (bank, forsakring, sjukvard, utbildning)
|
||||
|
||||
VARNINGSREGLER:
|
||||
- Representation/maltider: Max 300 kr/person exkl moms for avdragsratt (IL 16 kap 2§)
|
||||
- Gavor: Reklamgavor max 300 kr, representationsgavor max 180 kr
|
||||
- Blandad anvandning (telefon/dator): Bara yrkesmassig del avdragsgill
|
||||
- Bankavgifter, kortavgifter, valutavaxling: MOMSFRIA (exempt)
|
||||
|
||||
Foretagsform: ${entityLabel}
|
||||
Privatkonto: ${privateAccount}
|
||||
|
||||
REGLER:
|
||||
1. Negativt belopp = utgift: debitera kostnadskonto, kreditera 1930
|
||||
2. Positivt belopp = intakt: debitera 1930, kreditera intaktskonto
|
||||
3. Ge ett klart reasoning pa svenska som forklarar valet
|
||||
4. Lagg till warnings for avdragsbegransningar 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:
|
||||
- Anvandarens beskrivning: "${input.description}"
|
||||
- Banktext: "${input.transactionDescription}"
|
||||
- Belopp: ${input.transactionAmount} ${input.currency}
|
||||
- Datum: ${input.transactionDate}${input.merchantName ? `\n- Handlare: ${input.merchantName}` : ''}
|
||||
|
||||
Analysera och returnera bokforingsforslag 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 bokford pa standardkonto' : 'Intakt bokford pa 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,142 +0,0 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
export interface UserDescriptionMatchSettings {
|
||||
batchApplyEnabled: boolean
|
||||
minConfidenceThreshold: number
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: UserDescriptionMatchSettings = {
|
||||
batchApplyEnabled: true,
|
||||
minConfidenceThreshold: 0.55,
|
||||
}
|
||||
|
||||
export async function getSettings(userId: string): Promise<UserDescriptionMatchSettings> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', 'user-description-match')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<UserDescriptionMatchSettings>) }
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: string,
|
||||
partial: Partial<UserDescriptionMatchSettings>
|
||||
): Promise<UserDescriptionMatchSettings> {
|
||||
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: 'user-description-match',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handler
|
||||
// ============================================================
|
||||
|
||||
async function handleTransactionCategorized(
|
||||
payload: EventPayload<'transaction.categorized'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { transaction, userId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
if (!transaction.merchant_name) return
|
||||
|
||||
const settings = ctx
|
||||
? { ...(DEFAULT_SETTINGS), ...(await ctx.settings.get<Partial<UserDescriptionMatchSettings>>() || {}) }
|
||||
: await getSettings(userId)
|
||||
|
||||
if (!settings.batchApplyEnabled) return
|
||||
|
||||
try {
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
|
||||
// Check if the rule that fired was a user-described rule
|
||||
const { data: rule } = await supabase
|
||||
.from('mapping_rules')
|
||||
.select('id, user_description, merchant_pattern')
|
||||
.eq('user_id', userId)
|
||||
.eq('source', 'user_description')
|
||||
.ilike('merchant_pattern', transaction.merchant_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (!rule) return
|
||||
|
||||
// Count uncategorized siblings
|
||||
const { count } = await supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('merchant_name', transaction.merchant_name)
|
||||
.is('journal_entry_id', null)
|
||||
|
||||
if (!count || count === 0) return
|
||||
|
||||
// Store batch hint for the UI
|
||||
await supabase.from('extension_data').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'user-description-match',
|
||||
key: `batch_hint:${transaction.merchant_name}`,
|
||||
value: {
|
||||
merchant_name: transaction.merchant_name,
|
||||
uncategorized_count: count,
|
||||
user_description: rule.user_description,
|
||||
},
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
log.info(`[user-description-match] Batch hint stored: ${count} uncategorized for ${transaction.merchant_name}`)
|
||||
} catch (error) {
|
||||
log.error('[user-description-match] handleTransactionCategorized failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Object
|
||||
// ============================================================
|
||||
|
||||
export const userDescriptionMatchExtension: Extension = {
|
||||
id: 'user-description-match',
|
||||
name: 'Beskrivningsmatchning',
|
||||
version: '1.0.0',
|
||||
eventHandlers: [
|
||||
{ eventType: 'transaction.categorized', handler: handleTransactionCategorized },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'Beskrivningsmatchning',
|
||||
path: '/settings/extensions/user-description-match',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await ctx.settings.set('settings', DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"id": "user-description-match",
|
||||
"sector": "general",
|
||||
"exportName": "userDescriptionMatchExtension",
|
||||
"entryPoint": "@/extensions/general/user-description-match",
|
||||
"workspace": "@/components/extensions/general/UserDescriptionMatchWorkspace",
|
||||
"requiredEnvVars": [],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": [],
|
||||
"definition": {
|
||||
"name": "Beskrivningsmatchning",
|
||||
"category": "operations",
|
||||
"icon": "TextSearch",
|
||||
"dataPattern": "core",
|
||||
"readsCoreTables": ["transactions", "mapping_rules"],
|
||||
"description": "Matcha transaktioner med egna beskrivningar",
|
||||
"longDescription": "Beskriv vad en transaktion gäller med egna ord och få smarta bokföringsförslag. Systemet lär sig av dina beskrivningar och applicerar automatiskt på framtida transaktioner från samma leverantör."
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should have 9 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(9)
|
||||
it('should have 8 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(8)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -93,7 +93,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(9)
|
||||
expect(extensions.length).toBe(8)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
@@ -9,5 +9,4 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'calendar',
|
||||
'enable-banking',
|
||||
'email',
|
||||
'user-description-match',
|
||||
])
|
||||
|
||||
@@ -8,7 +8,6 @@ import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { calendarExtension } from '@/extensions/general/calendar'
|
||||
import { enableBankingExtension } from '@/extensions/general/enable-banking'
|
||||
import { emailExtension } from '@/extensions/general/email'
|
||||
import { userDescriptionMatchExtension } from '@/extensions/general/user-description-match'
|
||||
|
||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
receiptOcrExtension,
|
||||
@@ -19,5 +18,4 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
calendarExtension,
|
||||
enableBankingExtension,
|
||||
emailExtension,
|
||||
userDescriptionMatchExtension,
|
||||
]
|
||||
|
||||
@@ -136,19 +136,5 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
"company_settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "user-description-match",
|
||||
"name": "Beskrivningsmatchning",
|
||||
"sector": "general",
|
||||
"category": "operations",
|
||||
"icon": "TextSearch",
|
||||
"dataPattern": "core",
|
||||
"description": "Matcha transaktioner med egna beskrivningar",
|
||||
"longDescription": "Beskriv vad en transaktion gäller med egna ord och få smarta bokföringsförslag. Systemet lär sig av dina beskrivningar och applicerar automatiskt på framtida transaktioner från samma leverantör.",
|
||||
"readsCoreTables": [
|
||||
"transactions",
|
||||
"mapping_rules"
|
||||
]
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -11,5 +11,4 @@ export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>>
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/DocumentInboxWorkspace')),
|
||||
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
'general/user-description-match': dynamic(() => import('@/components/extensions/general/UserDescriptionMatchWorkspace')),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user