Merge remote-tracking branch 'origin/main' into export-biz
This commit is contained in:
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('findMatchingTemplates', () => {
|
||||
mcc_code: 5817,
|
||||
})
|
||||
const matches = findMatchingTemplates(tx)
|
||||
expect(matches.length).toBeLessThanOrEqual(5)
|
||||
expect(matches.length).toBeLessThanOrEqual(20)
|
||||
})
|
||||
|
||||
it('results are sorted by confidence descending', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
|
||||
import { BOOKING_TEMPLATES } from '../booking-templates'
|
||||
|
||||
// Mock server-only (no-op in tests)
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock OpenAI Embeddings
|
||||
vi.mock('@langchain/openai', () => {
|
||||
class MockOpenAIEmbeddings {
|
||||
embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1))
|
||||
embedDocuments = vi.fn().mockImplementation((texts: string[]) =>
|
||||
Promise.resolve(texts.map(() => new Array(1536).fill(0.1)))
|
||||
)
|
||||
}
|
||||
return { OpenAIEmbeddings: MockOpenAIEmbeddings }
|
||||
})
|
||||
|
||||
// Mock Supabase
|
||||
const { supabase: mockSupabase, mockResult } = createMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn().mockResolvedValue(mockSupabase),
|
||||
}))
|
||||
|
||||
describe('template-embeddings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('buildEmbeddingText', () => {
|
||||
it('includes all relevant fields for a template', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
// Should include Swedish and English name
|
||||
expect(text).toContain('Lokalhyra')
|
||||
expect(text).toContain('Office rent')
|
||||
|
||||
// Should include description
|
||||
expect(text).toContain(template.description_sv)
|
||||
|
||||
// Should include keywords
|
||||
expect(text).toContain('hyra')
|
||||
expect(text).toContain('lokal')
|
||||
|
||||
// Should include group
|
||||
expect(text).toContain('premises')
|
||||
|
||||
// Should include direction
|
||||
expect(text).toContain('utgift')
|
||||
|
||||
// Should include accounts
|
||||
expect(text).toContain('5010')
|
||||
expect(text).toContain('1930')
|
||||
})
|
||||
|
||||
it('includes VAT treatment when present', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
expect(text).toContain('standard_25')
|
||||
expect(text).toContain('25%')
|
||||
})
|
||||
|
||||
it('includes special rules when present', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
expect(text).toContain(template.special_rules_sv!)
|
||||
})
|
||||
|
||||
it('includes MCC codes when present', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
expect(text).toContain('4900')
|
||||
})
|
||||
|
||||
it('includes deductibility note for non-full deductibility', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
const template = BOOKING_TEMPLATES.find((t) => t.deductibility === 'non_deductible')!
|
||||
const text = buildEmbeddingText(template)
|
||||
|
||||
expect(text).toContain('non_deductible')
|
||||
})
|
||||
|
||||
it('generates text for all 100 templates without error', async () => {
|
||||
const { buildEmbeddingText } = await import('../template-embeddings')
|
||||
|
||||
for (const template of BOOKING_TEMPLATES) {
|
||||
const text = buildEmbeddingText(template)
|
||||
expect(text.length).toBeGreaterThan(10)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTransactionQueryText', () => {
|
||||
it('combines description, merchant, and direction', async () => {
|
||||
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'SPOTIFY PREMIUM',
|
||||
merchant_name: 'Spotify',
|
||||
amount: -109,
|
||||
mcc_code: 5815,
|
||||
})
|
||||
|
||||
const text = buildTransactionQueryText(tx)
|
||||
|
||||
expect(text).toContain('SPOTIFY PREMIUM')
|
||||
expect(text).toContain('Spotify')
|
||||
expect(text).toContain('MCC 5815')
|
||||
expect(text).toContain('utgift')
|
||||
})
|
||||
|
||||
it('marks positive amounts as income', async () => {
|
||||
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'Inbetalning',
|
||||
amount: 5000,
|
||||
})
|
||||
|
||||
const text = buildTransactionQueryText(tx)
|
||||
expect(text).toContain('intäkt')
|
||||
})
|
||||
|
||||
it('handles null merchant_name and mcc_code', async () => {
|
||||
const { buildTransactionQueryText } = await import('../template-embeddings')
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'Some payment',
|
||||
merchant_name: null,
|
||||
mcc_code: null,
|
||||
amount: -100,
|
||||
})
|
||||
|
||||
const text = buildTransactionQueryText(tx)
|
||||
expect(text).toContain('Some payment')
|
||||
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', () => {
|
||||
it('returns a consistent hash string', async () => {
|
||||
const { getSchemaVersion } = await import('../template-embeddings')
|
||||
|
||||
const v1 = getSchemaVersion()
|
||||
const v2 = getSchemaVersion()
|
||||
|
||||
expect(v1).toBe(v2)
|
||||
expect(v1).toHaveLength(12)
|
||||
expect(v1).toMatch(/^[a-f0-9]+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findSimilarTemplates', () => {
|
||||
it('returns empty array on RPC error (graceful fallback)', async () => {
|
||||
const { findSimilarTemplates } = await import('../template-embeddings')
|
||||
|
||||
// Mock staleness check
|
||||
mockResult({ data: { schema_version: 'test' }, error: null })
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'SPOTIFY',
|
||||
amount: -109,
|
||||
})
|
||||
|
||||
// The mock will return error for the RPC call
|
||||
mockResult({ data: null, error: { message: 'RPC failed' } })
|
||||
const results = await findSimilarTemplates(tx)
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when no embeddings exist', async () => {
|
||||
const { findSimilarTemplates } = await import('../template-embeddings')
|
||||
|
||||
mockResult({ data: [], error: null })
|
||||
|
||||
const tx = makeTransaction({
|
||||
description: 'Random purchase',
|
||||
amount: -50,
|
||||
})
|
||||
|
||||
const results = await findSimilarTemplates(tx)
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -617,7 +617,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
||||
deductibility: 'full',
|
||||
special_rules_sv: 'Ofta utländsk leverantör (USA) med omvänd skattskyldighet',
|
||||
mcc_codes: [],
|
||||
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot'],
|
||||
keywords: ['openai', 'chatgpt', 'anthropic', 'claude', 'ai', 'midjourney', 'copilot', 'mistral', 'claude', 'gemini'],
|
||||
risk_level: 'NONE',
|
||||
requires_review: false,
|
||||
impact_score: 7,
|
||||
@@ -663,7 +663,7 @@ export const BOOKING_TEMPLATES: readonly BookingTemplate[] = [
|
||||
vat_rate: 0.25,
|
||||
deductibility: 'full',
|
||||
mcc_codes: [5111, 5112, 5943, 5944],
|
||||
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror'],
|
||||
keywords: ['kontorsmaterial', 'pennor', 'papper', 'office supplies', 'staples', 'kontorsvaror', 'kontor'],
|
||||
risk_level: 'NONE',
|
||||
requires_review: false,
|
||||
impact_score: 7,
|
||||
@@ -2534,7 +2534,7 @@ export function findMatchingTemplates(
|
||||
|
||||
return results
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 5)
|
||||
.slice(0, 20)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Template Embeddings Module
|
||||
*
|
||||
* SERVER-ONLY: Uses OpenAI embeddings and Supabase service client.
|
||||
*
|
||||
* Provides semantic search over booking templates using pgvector.
|
||||
* Templates are pre-embedded and stored in the database. Transaction
|
||||
* text is embedded at query time and compared via cosine similarity.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import {
|
||||
BOOKING_TEMPLATES,
|
||||
getTemplateById,
|
||||
type BookingTemplate,
|
||||
type TemplateMatch,
|
||||
} from './booking-templates'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
export const EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
const EMBEDDING_LOGIC_VERSION = '1'
|
||||
const MATCH_COUNT = 20
|
||||
const MATCH_THRESHOLD = 0.5
|
||||
|
||||
/**
|
||||
* Schema version is a hash of the model + embedding logic version.
|
||||
* Bump EMBEDDING_LOGIC_VERSION when buildEmbeddingText changes.
|
||||
*/
|
||||
export function getSchemaVersion(): string {
|
||||
return createHash('sha256')
|
||||
.update(`${EMBEDDING_MODEL}:${EMBEDDING_LOGIC_VERSION}`)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Embedding Text Builders
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Build a rich text representation of a template for embedding.
|
||||
* Includes all semantically relevant fields.
|
||||
*/
|
||||
export function buildEmbeddingText(template: BookingTemplate): string {
|
||||
const parts: string[] = []
|
||||
|
||||
parts.push(`${template.name_sv} (${template.name_en})`)
|
||||
parts.push(template.description_sv)
|
||||
|
||||
if (template.keywords.length > 0) {
|
||||
parts.push(`Nyckelord: ${template.keywords.join(', ')}`)
|
||||
}
|
||||
|
||||
parts.push(`Grupp: ${template.group}`)
|
||||
parts.push(`Typ: ${template.direction === 'expense' ? 'utgift' : template.direction === 'income' ? 'intäkt' : 'överföring'}`)
|
||||
parts.push(`Konton: ${template.debit_account} (debet) / ${template.credit_account} (kredit)`)
|
||||
|
||||
if (template.vat_treatment) {
|
||||
parts.push(`Moms: ${template.vat_treatment} (${template.vat_rate * 100}%)`)
|
||||
}
|
||||
|
||||
if (template.special_rules_sv) {
|
||||
parts.push(`Regler: ${template.special_rules_sv}`)
|
||||
}
|
||||
|
||||
if (template.mcc_codes.length > 0) {
|
||||
parts.push(`MCC-koder: ${template.mcc_codes.join(', ')}`)
|
||||
}
|
||||
|
||||
if (template.deductibility !== 'full') {
|
||||
parts.push(`Avdragsrätt: ${template.deductibility}`)
|
||||
}
|
||||
|
||||
return parts.join('. ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
userDescription?: string
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (userDescription) {
|
||||
parts.push(userDescription)
|
||||
}
|
||||
|
||||
if (transaction.description) {
|
||||
parts.push(transaction.description)
|
||||
}
|
||||
|
||||
if (transaction.merchant_name) {
|
||||
parts.push(transaction.merchant_name)
|
||||
}
|
||||
|
||||
if (transaction.mcc_code) {
|
||||
parts.push(`MCC ${transaction.mcc_code}`)
|
||||
}
|
||||
|
||||
parts.push(transaction.amount < 0 ? 'utgift' : 'intäkt')
|
||||
|
||||
return parts.join(' — ')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Embeddings Client
|
||||
// ============================================================
|
||||
|
||||
let embeddingsInstance: OpenAIEmbeddings | null = null
|
||||
|
||||
function getEmbeddingsClient(): OpenAIEmbeddings {
|
||||
if (!embeddingsInstance) {
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
modelName: EMBEDDING_MODEL,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
}
|
||||
return embeddingsInstance
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Seed All Template Embeddings
|
||||
// ============================================================
|
||||
|
||||
export async function seedAllTemplateEmbeddings(): Promise<{
|
||||
seeded: number
|
||||
errors: string[]
|
||||
}> {
|
||||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createServiceClient()
|
||||
const embeddings = getEmbeddingsClient()
|
||||
const schemaVersion = getSchemaVersion()
|
||||
const errors: string[] = []
|
||||
|
||||
// Build texts for all templates
|
||||
const texts = BOOKING_TEMPLATES.map((t) => buildEmbeddingText(t))
|
||||
|
||||
// Batch embed all texts
|
||||
let vectors: number[][]
|
||||
try {
|
||||
vectors = await embeddings.embedDocuments(texts)
|
||||
} catch (error) {
|
||||
return { seeded: 0, errors: [`Embedding generation failed: ${error}`] }
|
||||
}
|
||||
|
||||
// Upsert each template embedding
|
||||
let seeded = 0
|
||||
for (let i = 0; i < BOOKING_TEMPLATES.length; i++) {
|
||||
const template = BOOKING_TEMPLATES[i]
|
||||
const { error } = await supabase
|
||||
.from('booking_template_embeddings')
|
||||
.upsert(
|
||||
{
|
||||
template_id: template.id,
|
||||
embedding: JSON.stringify(vectors[i]),
|
||||
embedding_text: texts[i],
|
||||
model: EMBEDDING_MODEL,
|
||||
schema_version: schemaVersion,
|
||||
},
|
||||
{ onConflict: 'template_id' }
|
||||
)
|
||||
|
||||
if (error) {
|
||||
errors.push(`Failed to upsert ${template.id}: ${error.message}`)
|
||||
} else {
|
||||
seeded++
|
||||
}
|
||||
}
|
||||
|
||||
return { seeded, errors }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Find Similar Templates (Semantic Search)
|
||||
// ============================================================
|
||||
|
||||
let stalenessWarned = false
|
||||
|
||||
export async function findSimilarTemplates(
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType,
|
||||
matchCount: number = MATCH_COUNT,
|
||||
userDescription?: string
|
||||
): Promise<TemplateMatch[]> {
|
||||
try {
|
||||
const { createServiceClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createServiceClient()
|
||||
const embeddings = getEmbeddingsClient()
|
||||
|
||||
// Check schema version staleness on first call
|
||||
if (!stalenessWarned) {
|
||||
const { data: sample } = await supabase
|
||||
.from('booking_template_embeddings')
|
||||
.select('schema_version')
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (sample && sample.schema_version !== getSchemaVersion()) {
|
||||
console.warn(
|
||||
`[template-embeddings] Schema version mismatch: DB has "${sample.schema_version}", current is "${getSchemaVersion()}". Re-seed embeddings.`
|
||||
)
|
||||
}
|
||||
stalenessWarned = true
|
||||
}
|
||||
|
||||
// Embed the transaction query text
|
||||
const queryText = buildTransactionQueryText(transaction, userDescription)
|
||||
const queryVector = await embeddings.embedQuery(queryText)
|
||||
|
||||
// Request extra results to account for post-filtering
|
||||
const requestCount = matchCount + 10
|
||||
|
||||
const { data, error } = await supabase.rpc('match_booking_templates', {
|
||||
query_embedding: JSON.stringify(queryVector),
|
||||
match_count: requestCount,
|
||||
match_threshold: MATCH_THRESHOLD,
|
||||
})
|
||||
|
||||
if (error || !data) {
|
||||
console.error('[template-embeddings] RPC error:', error)
|
||||
return []
|
||||
}
|
||||
|
||||
// Map RPC results to TemplateMatch[], filtering by entity type and direction
|
||||
const isExpense = transaction.amount < 0
|
||||
const isIncome = transaction.amount > 0
|
||||
const results: TemplateMatch[] = []
|
||||
|
||||
for (const row of data as { template_id: string; similarity: number }[]) {
|
||||
const template = getTemplateById(row.template_id)
|
||||
if (!template) continue
|
||||
|
||||
// Filter by entity applicability
|
||||
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by direction
|
||||
if (template.direction === 'expense' && !isExpense) continue
|
||||
if (template.direction === 'income' && !isIncome) continue
|
||||
|
||||
results.push({
|
||||
template,
|
||||
confidence: Math.round(row.similarity * 100) / 100,
|
||||
})
|
||||
|
||||
if (results.length >= matchCount) break
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
console.error('[template-embeddings] findSimilarTemplates failed:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Inbox,
|
||||
Landmark,
|
||||
UtensilsCrossed,
|
||||
ChefHat,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
BarChart3,
|
||||
Layers,
|
||||
Puzzle,
|
||||
TextSearch,
|
||||
Ship,
|
||||
FileText,
|
||||
Shield,
|
||||
@@ -34,6 +36,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Inbox,
|
||||
Landmark,
|
||||
UtensilsCrossed,
|
||||
ChefHat,
|
||||
@@ -54,6 +57,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
||||
BarChart3,
|
||||
Layers,
|
||||
Puzzle,
|
||||
TextSearch,
|
||||
Ship,
|
||||
FileText,
|
||||
Shield,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { sruExportExtension } from '@/extensions/sru-export'
|
||||
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 { euSalesListExtension } from '@/extensions/export/eu-sales-list'
|
||||
import { vatMonitorExtension } from '@/extensions/export/vat-monitor'
|
||||
import { intrastatExtension } from '@/extensions/export/intrastat'
|
||||
@@ -32,6 +34,8 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
neBilagaExtension,
|
||||
aiChatExtension,
|
||||
invoiceInboxExtension,
|
||||
calendarExtension,
|
||||
userDescriptionMatchExtension,
|
||||
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
|
||||
|
||||
// ── Export sector ──────────────────────────────────────────
|
||||
|
||||
@@ -51,6 +51,12 @@ export const SECTORS: Sector[] = [
|
||||
description: 'AI-assistent för skatte- och bokföringsfrågor',
|
||||
longDescription:
|
||||
'Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.',
|
||||
quickAction: {
|
||||
label: 'AI-assistent',
|
||||
description: 'Fråga om bokföring',
|
||||
icon: 'MessageSquare',
|
||||
event: 'open-ai-chat',
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: 'push-notifications',
|
||||
@@ -76,6 +82,30 @@ export const SECTORS: Sector[] = [
|
||||
longDescription:
|
||||
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
|
||||
},
|
||||
{
|
||||
slug: 'calendar',
|
||||
name: 'Kalender',
|
||||
sector: 'general',
|
||||
category: 'operations',
|
||||
icon: 'Calendar',
|
||||
dataPattern: 'core',
|
||||
readsCoreTables: ['invoices', 'deadlines', 'customers'],
|
||||
description: 'Fullstandig kalendervy med manads-, vecko- och dagsvisning',
|
||||
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)',
|
||||
|
||||
@@ -15,6 +15,16 @@ export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | '
|
||||
/** How an extension gets its data */
|
||||
export type ExtensionDataPattern = 'core' | 'manual' | 'both'
|
||||
|
||||
/** Dashboard quick action declared by an extension */
|
||||
export interface QuickActionDefinition {
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
href?: string
|
||||
event?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
/** Extension metadata for the marketplace and workspace routing */
|
||||
export interface ExtensionDefinition {
|
||||
slug: string
|
||||
@@ -28,6 +38,7 @@ export interface ExtensionDefinition {
|
||||
dataPattern: ExtensionDataPattern
|
||||
readsCoreTables?: string[]
|
||||
hasOwnData?: boolean
|
||||
quickAction?: QuickActionDefinition
|
||||
}
|
||||
|
||||
/** Sector definition with its extensions */
|
||||
|
||||
@@ -14,7 +14,9 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
|
||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
||||
'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')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { suggestCategory } from '@/lib/tax/expense-warnings'
|
||||
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
|
||||
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
|
||||
|
||||
export interface SuggestedCategory {
|
||||
@@ -9,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> = {
|
||||
@@ -22,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',
|
||||
@@ -71,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,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',
|
||||
@@ -150,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({
|
||||
@@ -174,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)
|
||||
}
|
||||
|
||||
@@ -198,13 +239,25 @@ export interface SuggestedTemplate {
|
||||
|
||||
/**
|
||||
* Get suggested booking templates for a transaction.
|
||||
* Uses multi-signal matching (MCC, keywords, description patterns).
|
||||
* Tries embedding-based semantic search first, falls back to keyword matching.
|
||||
*/
|
||||
export function getSuggestedTemplates(
|
||||
export async function getSuggestedTemplates(
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType
|
||||
): SuggestedTemplate[] {
|
||||
const matches = findMatchingTemplates(transaction, entityType)
|
||||
): Promise<SuggestedTemplate[]> {
|
||||
let matches: TemplateMatch[]
|
||||
|
||||
try {
|
||||
matches = await findSimilarTemplates(transaction, entityType)
|
||||
} catch {
|
||||
matches = []
|
||||
}
|
||||
|
||||
// Fall back to keyword matching if embedding search returns nothing
|
||||
if (matches.length === 0) {
|
||||
matches = findMatchingTemplates(transaction, entityType)
|
||||
}
|
||||
|
||||
return matches.map((m: TemplateMatch) => ({
|
||||
template_id: m.template.id,
|
||||
name_sv: m.template.name_sv,
|
||||
|
||||
Reference in New Issue
Block a user