feat: transaction categorization UX improvements and description matching

Add journal entry preview, human-readable account names, auto-apply VAT,
fallback template suggestions, example prompts, invoice match comparison,
and batch result feedback. Also includes user-description-match extension,
describe/batch-describe API routes, improved AI categorization with multi-
suggestion support, and template embedding search.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-24 16:02:06 +01:00
co-authored by Claude Opus 4.6
parent 6cef4e11eb
commit 3e7fa45ed6
40 changed files with 2401 additions and 84 deletions
@@ -87,14 +87,32 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
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_other: { account: '6991', label: 'Övriga kostnader' },
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
}
}
/** Fallback template IDs when AI doesn't provide one */
const CATEGORY_DEFAULT_TEMPLATES: Record<string, string> = {
expense_representation: 'representation_external',
expense_equipment: 'equipment_tools',
expense_software: 'software_subscription',
expense_travel: 'travel_domestic',
expense_office: 'office_supplies_general',
expense_consumables: 'office_supplies_general',
expense_vehicle: 'travel_fuel',
expense_telecom: 'telecom_mobile',
expense_marketing: 'marketing_advertising',
expense_education: 'education_course',
expense_professional_services: 'consulting_accounting',
}
/**
* Build template reference from candidate templates (pre-filtered by embeddings)
* or fall back to full template list if no candidates provided.
@@ -114,12 +132,8 @@ function getTemplateReference(
}
const NON_DEDUCTIBLE_RULES = `
ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35)
- Gym/träning: Inte avdragsgilla som personlig kostnad (IL 9 kap 2§)
- Kosmetika/hudvård: Normalt inte avdragsgillt
- Frisör: Normalt privat kostnad
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§)
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
`
@@ -140,15 +154,15 @@ const CLASSIFY_TOOL: Anthropic.Tool = {
type: 'object',
properties: {
transactionId: { type: 'string', description: 'Transaction ID' },
templateId: { type: 'string', description: 'Booking template ID (from the provided templates list)' },
category: { type: 'string', description: 'Transaction category (e.g. expense_software, income_services, private)' },
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', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
required: ['transactionId', 'templateId', 'category', 'basAccount', 'confidence', 'reasoning', 'isPrivate'],
},
},
},
@@ -232,12 +246,14 @@ ${NON_DEDUCTIBLE_RULES}
REGLER:
1. Negativa belopp = utgifter, positiva = intäkter
2. Markera transaktioner som troligen är privata med isPrivate: true
3. Ange confidence 0.0-1.0 baserat på hur säker du är
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. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private"
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata
7. Ange templateId om en bokföringsmall matchar (föredra mallar framför generiska kategorier)`
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.`
const historyContext =
context.recentHistory.length > 0
@@ -257,7 +273,8 @@ REGLER:
)
.join('\n\n')
const userPrompt = `Kategorisera följande transaktioner med classify_transactions-verktyget:
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:
@@ -322,6 +339,7 @@ ${transactionList}`
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(
@@ -330,9 +348,23 @@ ${transactionList}`
)
.filter((s) => validTransactionIds.has(s.transactionId as string))
.map((s) => {
const category = validCategories.has(s.category as string)
// 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]
@@ -343,8 +375,8 @@ ${transactionList}`
taxCode: (s.taxCode as string) || null,
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
reasoning: (s.reasoning as string) || '',
isPrivate: category === 'private' || Boolean(s.isPrivate),
templateId: (s.templateId as string) || undefined,
isPrivate: false,
templateId: (s.templateId as string) || CATEGORY_DEFAULT_TEMPLATES[category] || undefined,
}
})
}
+15 -3
View File
@@ -323,16 +323,28 @@ async function storeSuggestions(
// 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) {
await supabase.from('extension_data').upsert(
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:${suggestion.transactionId}`,
value: suggestion,
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)
}
}
}
@@ -28,6 +28,10 @@ const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
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
@@ -273,6 +277,10 @@ export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
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',
@@ -0,0 +1,142 @@
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)
},
}