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:
co-authored by
Claude Opus 4.6
parent
6cef4e11eb
commit
3e7fa45ed6
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user