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
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockSupabase, makeTransaction } from '@/tests/helpers'
// Mock Supabase
const { supabase: mockSupabase, mockResult } = createMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue(mockSupabase),
}))
// Mock booking-templates (needed by evaluateMappingRules)
vi.mock('../booking-templates', () => ({
findMatchingTemplates: vi.fn().mockReturnValue([]),
buildMappingResultFromTemplate: vi.fn(),
}))
describe('mapping-engine', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('saveUserMappingRule', () => {
it('saves auto-learned rule without user description', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
await saveUserMappingRule('user-1', 'ICA Maxi', '5410', '1930', false)
// Verify insert was called via supabase.from().insert()
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
it('saves user-described rule with priority 5 and confidence 0.98', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
await saveUserMappingRule(
'user-1',
'Restaurant XYZ',
'6071',
'1930',
false,
'business lunch with client',
'restaurant_dining'
)
// Verify from was called (first for delete, then for insert)
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
it('does not throw on insert error (non-critical)', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: { message: 'DB error' } })
// Should not throw
await expect(
saveUserMappingRule('user-1', 'ICA Maxi', '5410', '1930', false)
).resolves.toBeUndefined()
})
it('escapes special regex characters in merchant name', async () => {
const { saveUserMappingRule } = await import('../mapping-engine')
mockResult({ data: null, error: null })
// Merchant name with regex special chars
await saveUserMappingRule('user-1', 'Test (Pty) Ltd.', '5410', '1930', false)
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
})
describe('evaluateMappingRules', () => {
it('returns default result when no rules match', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
mockResult({ data: [], error: null })
const result = await evaluateMappingRules('user-1', tx)
expect(result.debit_account).toBe('6991')
expect(result.credit_account).toBe('1930')
expect(result.confidence).toBe(0.1)
expect(result.requires_review).toBe(true)
})
it('matches merchant_pattern rule', async () => {
const { evaluateMappingRules } = await import('../mapping-engine')
const tx = makeTransaction({
amount: -299,
merchant_name: 'ICA Maxi',
description: 'ICA MAXI STOCKHOLM',
})
mockResult({
data: [
{
id: 'rule-1',
user_id: 'user-1',
rule_name: 'Learned: ICA Maxi',
rule_type: 'merchant_name',
priority: 10,
mcc_codes: null,
merchant_pattern: 'ICA Maxi',
description_pattern: null,
amount_min: null,
amount_max: null,
debit_account: '5410',
credit_account: '1930',
vat_treatment: null,
vat_debit_account: null,
vat_credit_account: null,
risk_level: 'NONE',
default_private: false,
requires_review: false,
confidence_score: 0.95,
capitalization_threshold: null,
capitalized_debit_account: null,
is_active: true,
source: 'auto',
user_description: null,
template_id: null,
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
],
error: null,
})
const result = await evaluateMappingRules('user-1', tx)
expect(result.debit_account).toBe('5410')
expect(result.credit_account).toBe('1930')
expect(result.confidence).toBe(0.95)
})
})
})
@@ -149,6 +149,40 @@ describe('template-embeddings', () => {
expect(text).toContain('utgift')
expect(text).not.toContain('MCC')
})
it('prepends user description when provided', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SWE REST 4521 STHLM',
merchant_name: 'Unknown',
amount: -450,
})
const text = buildTransactionQueryText(tx, 'business lunch with client')
// User description should appear first
expect(text.indexOf('business lunch with client')).toBe(0)
// Transaction data should still be present
expect(text).toContain('SWE REST 4521 STHLM')
expect(text).toContain('Unknown')
expect(text).toContain('utgift')
})
it('behaves identically when userDescription is undefined', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
})
const withoutDesc = buildTransactionQueryText(tx)
const withUndefined = buildTransactionQueryText(tx, undefined)
expect(withoutDesc).toBe(withUndefined)
})
})
describe('getSchemaVersion', () => {
+16
View File
@@ -71,6 +71,10 @@ export function getCategoryAccountMapping(
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_education: educationAccount,
expense_representation: '6071', // Representation, avdragsgill
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
@@ -224,6 +228,10 @@ export function buildMappingResultFromCategory(
expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänst',
expense_education: 'Utbildning',
expense_representation: 'Representation',
expense_consumables: 'Förbrukningsvaror',
expense_vehicle: 'Bil & drivmedel',
expense_telecom: 'Telefon & internet',
expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling',
@@ -262,6 +270,10 @@ export function getExpenseAccountForCategory(category: TransactionCategory): str
expense_marketing: '5910',
expense_professional_services: '6530',
expense_education: '6991',
expense_representation: '6071',
expense_consumables: '5460',
expense_vehicle: '5611',
expense_telecom: '6200',
expense_bank_fees: '6570',
expense_card_fees: '6570',
expense_currency_exchange: '7960',
@@ -292,6 +304,10 @@ export function getDefaultAccountForCategory(
expense_marketing: '5910',
expense_professional_services: '6530',
expense_education: entityType === 'aktiebolag' ? '7610' : '6991',
expense_representation: '6071',
expense_consumables: '5460',
expense_vehicle: '5611',
expense_telecom: '6200',
expense_bank_fees: '6570',
expense_card_fees: '6570',
expense_currency_exchange: '7960',
+70
View File
@@ -0,0 +1,70 @@
/**
* Client-safe account name map for UI display.
* Covers the ~30 accounts used in transaction categorization.
* No server dependencies — safe for 'use client' components.
*/
const ACCOUNT_NAMES: Record<string, string> = {
// Assets (1xxx)
'1510': 'Kundfordringar',
'1930': 'Foretagskonto',
// Equity & Liabilities (2xxx)
'2013': 'Ovriga egna uttag',
'2440': 'Leverantorsskulder',
'2611': 'Utg. moms 25%',
'2621': 'Utg. moms 12%',
'2631': 'Utg. moms 6%',
'2614': 'Utg. moms omvand',
'2641': 'Ing. moms',
'2645': 'Beraknad ing. moms',
'2893': 'Skuld till agare',
// Revenue (3xxx)
'3001': 'Forsaljning 25%',
'3002': 'Forsaljning 12%',
'3003': 'Forsaljning 6%',
'3305': 'Exportforsaljning',
'3308': 'EU-tjanster',
'3900': 'Ovriga rorelseintakter',
// Cost of goods (4xxx)
'4010': 'Varuinkop',
// External expenses (5xxx)
'5010': 'Lokalhyra',
'5410': 'Forbrukningsinventarier',
'5420': 'Programvaror',
'5460': 'Forbrukningsvaror',
'5611': 'Drivmedel bil',
'5800': 'Resekostnader',
'5910': 'Annonsering',
// Other external expenses (6xxx)
'6071': 'Representation',
'6200': 'Telefon & internet',
'6530': 'Redovisningstjanster',
'6570': 'Bankavgifter',
'6991': 'Ovriga kostnader',
// Personnel (7xxx)
'7610': 'Utbildning',
'7960': 'Valutakursforluster',
'3960': 'Valutakursvinster',
}
/**
* Get the Swedish display name for an account number.
* Returns the number itself if no name is mapped.
*/
export function getAccountName(accountNumber: string): string {
return ACCOUNT_NAMES[accountNumber] || accountNumber
}
/**
* Format an account number with its name, e.g. "5010 Lokalhyra".
*/
export function formatAccountWithName(accountNumber: string): string {
const name = ACCOUNT_NAMES[accountNumber]
return name ? `${accountNumber} ${name}` : accountNumber
}
+60 -17
View File
@@ -227,35 +227,78 @@ function getDefaultResult(transaction: Transaction): MappingResult {
}
/**
* Save a user-level mapping rule learned from categorization
* Save a user-level mapping rule learned from categorization.
*
* When userDescription is provided, the rule gets:
* - source: 'user_description' (instead of 'auto')
* - priority: 5 (beats auto-learned at 10)
* - confidence_score: 0.98
* - The original user text and template_id stored for UI display
*
* User-described rules for the same merchant replace prior user-described rules
* (latest description wins).
*/
export async function saveUserMappingRule(
userId: string,
merchantName: string,
debitAccount: string,
creditAccount: string,
isPrivate: boolean
isPrivate: boolean,
userDescription?: string,
templateId?: string
): Promise<void> {
const supabase = await createClient()
// Escape special regex characters in merchant name
const escapedMerchant = merchantName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const { error } = await supabase.from('mapping_rules').insert({
user_id: userId,
rule_name: `Learned: ${merchantName}`,
rule_type: 'merchant_name',
priority: 10, // User overrides have highest priority
merchant_pattern: escapedMerchant,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.95,
})
if (userDescription) {
// Delete existing user_description rule for this merchant (latest wins)
await supabase
.from('mapping_rules')
.delete()
.eq('user_id', userId)
.eq('merchant_pattern', escapedMerchant)
.eq('source', 'user_description')
if (error) {
// Silently fail — saving learned rules is non-critical
const { error } = await supabase.from('mapping_rules').insert({
user_id: userId,
rule_name: `Described: ${merchantName}`,
rule_type: 'merchant_name',
priority: 5,
merchant_pattern: escapedMerchant,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.98,
source: 'user_description',
user_description: userDescription,
template_id: templateId || null,
})
if (error) {
// Silently fail — saving learned rules is non-critical
}
} else {
const { error } = await supabase.from('mapping_rules').insert({
user_id: userId,
rule_name: `Learned: ${merchantName}`,
rule_type: 'merchant_name',
priority: 10,
merchant_pattern: escapedMerchant,
debit_account: debitAccount,
credit_account: creditAccount,
risk_level: 'NONE',
default_private: isPrivate,
requires_review: false,
confidence_score: 0.95,
source: 'auto',
})
if (error) {
// Silently fail — saving learned rules is non-critical
}
}
}
+13 -3
View File
@@ -82,10 +82,19 @@ export function buildEmbeddingText(template: BookingTemplate): string {
/**
* Build query text from a transaction for embedding search.
* When userDescription is provided, it is prepended so it dominates
* the semantic search (user intent > raw bank text).
*/
export function buildTransactionQueryText(transaction: Transaction): string {
export function buildTransactionQueryText(
transaction: Transaction,
userDescription?: string
): string {
const parts: string[] = []
if (userDescription) {
parts.push(userDescription)
}
if (transaction.description) {
parts.push(transaction.description)
}
@@ -180,7 +189,8 @@ let stalenessWarned = false
export async function findSimilarTemplates(
transaction: Transaction,
entityType?: EntityType,
matchCount: number = MATCH_COUNT
matchCount: number = MATCH_COUNT,
userDescription?: string
): Promise<TemplateMatch[]> {
try {
const { createServiceClient } = await import('@/lib/supabase/server')
@@ -204,7 +214,7 @@ export async function findSimilarTemplates(
}
// Embed the transaction query text
const queryText = buildTransactionQueryText(transaction)
const queryText = buildTransactionQueryText(transaction, userDescription)
const queryVector = await embeddings.embedQuery(queryText)
// Request extra results to account for post-filtering