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
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user