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
@@ -72,6 +72,10 @@ export const TransactionCategorySchema = z.enum([
|
||||
'expense_marketing',
|
||||
'expense_professional_services',
|
||||
'expense_education',
|
||||
'expense_representation',
|
||||
'expense_consumables',
|
||||
'expense_vehicle',
|
||||
'expense_telecom',
|
||||
'expense_bank_fees',
|
||||
'expense_card_fees',
|
||||
'expense_currency_exchange',
|
||||
@@ -306,6 +310,7 @@ export const CategorizeTransactionSchema = z.object({
|
||||
template_id: z.string().optional(),
|
||||
vat_treatment: VatTreatmentSchema.optional(),
|
||||
account_override: accountNumber.optional(),
|
||||
user_description: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export const BookTransactionSchema = z.object({
|
||||
@@ -323,6 +328,17 @@ export const MatchSupplierInvoiceSchema = z.object({
|
||||
supplier_invoice_id: uuid,
|
||||
})
|
||||
|
||||
export const DescribeTransactionSchema = z.object({
|
||||
description: z.string().min(3).max(500),
|
||||
})
|
||||
|
||||
export const BatchDescribeSchema = z.object({
|
||||
merchant_name: z.string().min(1),
|
||||
template_id: z.string().min(1),
|
||||
is_business: z.boolean(),
|
||||
user_description: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Settings schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,8 +11,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(6)
|
||||
})
|
||||
|
||||
it('should have 18 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(19)
|
||||
it('should have 20 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(20)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
BarChart3,
|
||||
Layers,
|
||||
Puzzle,
|
||||
TextSearch,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -53,6 +54,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
BarChart3,
|
||||
Layers,
|
||||
Puzzle,
|
||||
TextSearch,
|
||||
}
|
||||
|
||||
export function resolveIcon(name: string): LucideIcon {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { calendarExtension } from '@/extensions/general/calendar'
|
||||
import { userDescriptionMatchExtension } from '@/extensions/general/user-description-match'
|
||||
import type { Extension } from './types'
|
||||
|
||||
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
|
||||
@@ -30,6 +31,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
aiChatExtension,
|
||||
invoiceInboxExtension,
|
||||
calendarExtension,
|
||||
userDescriptionMatchExtension,
|
||||
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
|
||||
]
|
||||
|
||||
|
||||
@@ -94,6 +94,18 @@ export const SECTORS: Sector[] = [
|
||||
longDescription:
|
||||
'Se alla fakturadatum och deadlines i en interaktiv kalender med manads-, vecko- och dagsvy.',
|
||||
},
|
||||
{
|
||||
slug: 'user-description-match',
|
||||
name: 'Beskrivningsmatchning',
|
||||
sector: 'general',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
slug: 'enable-banking',
|
||||
name: 'Bankintegration (PSD2)',
|
||||
|
||||
@@ -16,6 +16,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
||||
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
|
||||
'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')),
|
||||
// Restaurant
|
||||
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
|
||||
'restaurant/earnings-per-liter': dynamic(() => import('@/components/extensions/restaurant/EarningsPerLiterWorkspace')),
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('generateSIEExport', () => {
|
||||
|
||||
const output = await generateSIEExport('user-1', {
|
||||
...baseOptions,
|
||||
org_number: undefined,
|
||||
org_number: null,
|
||||
})
|
||||
|
||||
expect(output).not.toContain('#ORGNR')
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface SuggestedCategory {
|
||||
account: string | null
|
||||
confidence: number
|
||||
source: 'mapping_rule' | 'pattern' | 'history' | 'ai'
|
||||
match_reason?: string
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -23,6 +24,10 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
expense_marketing: 'Marknadsföring',
|
||||
expense_professional_services: 'Konsulter',
|
||||
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',
|
||||
@@ -72,13 +77,17 @@ export function getSuggestedCategories(
|
||||
const category = accountToCategory(rule.debit_account, transaction.amount)
|
||||
if (category && !seen.has(category)) {
|
||||
seen.add(category)
|
||||
suggestions.push({
|
||||
const suggestion: SuggestedCategory = {
|
||||
category: category as TransactionCategory,
|
||||
label: CATEGORY_LABELS[category] || category,
|
||||
account: rule.debit_account,
|
||||
confidence: rule.confidence_score || 0.8,
|
||||
source: 'mapping_rule',
|
||||
})
|
||||
}
|
||||
if (rule.source === 'user_description' && rule.user_description) {
|
||||
suggestion.match_reason = `Matchad på din beskrivning: ${rule.user_description}`
|
||||
}
|
||||
suggestions.push(suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,9 +149,14 @@ function accountToCategory(account: string, amount: number): string | null {
|
||||
const expenseMap: Record<string, string> = {
|
||||
'5410': 'expense_equipment',
|
||||
'5420': 'expense_software',
|
||||
'5460': 'expense_consumables',
|
||||
'5611': 'expense_vehicle',
|
||||
'5800': 'expense_travel',
|
||||
'5010': 'expense_office',
|
||||
'5910': 'expense_marketing',
|
||||
'6071': 'expense_representation',
|
||||
'6072': 'expense_representation',
|
||||
'6200': 'expense_telecom',
|
||||
'6530': 'expense_professional_services',
|
||||
'6570': 'expense_bank_fees',
|
||||
'6991': 'expense_other',
|
||||
@@ -151,19 +165,40 @@ function accountToCategory(account: string, amount: number): string | null {
|
||||
return expenseMap[account] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Source priority for sorting — higher-quality sources rank first.
|
||||
* AI and mapping rules always beat history-based guesses.
|
||||
*/
|
||||
const SOURCE_PRIORITY: Record<SuggestedCategory['source'], number> = {
|
||||
mapping_rule: 3,
|
||||
ai: 2,
|
||||
pattern: 1,
|
||||
history: 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge AI-generated suggestions into existing suggestion list.
|
||||
* AI suggestions take priority over history-based ones.
|
||||
* Deduplicates by category, preserving the higher-confidence entry.
|
||||
* When transactionAmount is provided, filters out wrong-direction suggestions.
|
||||
*/
|
||||
export function mergeAiSuggestions(
|
||||
existing: SuggestedCategory[],
|
||||
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[]
|
||||
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[],
|
||||
transactionAmount?: number
|
||||
): SuggestedCategory[] {
|
||||
const seen = new Set<string>(existing.map((s) => s.category))
|
||||
const merged = [...existing]
|
||||
|
||||
for (const ai of aiSuggestions) {
|
||||
if (seen.has(ai.category)) continue
|
||||
|
||||
// Skip suggestions that don't match transaction direction
|
||||
if (transactionAmount !== undefined) {
|
||||
if (transactionAmount > 0 && ai.category.startsWith('expense_')) continue
|
||||
if (transactionAmount < 0 && ai.category.startsWith('income_')) continue
|
||||
}
|
||||
|
||||
seen.add(ai.category)
|
||||
|
||||
merged.push({
|
||||
@@ -175,8 +210,13 @@ export function mergeAiSuggestions(
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by source priority first, then by confidence within the same tier
|
||||
return merged
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source]
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
return b.confidence - a.confidence
|
||||
})
|
||||
.slice(0, 5)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user