Files
accounted/lib/transactions/category-suggestions.ts
T
Jakob WennbergandClaude Opus 4.6 d9c95a7b59 feat: counterparty templates with multi-line patterns, batch matching, and settings cleanup (#118)
* feat: separate AR/AP/accounting into distinct nav groups (#92)

Split the flat "Finans" sidebar group into three visually distinct
sections — Försäljning (AR), Inköp (AP), and Redovisning — so users
coming from Fortnox immediately find customer invoicing and supplier
invoices as top-level concepts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: journal entry detail view, correction chain, and account name display

- Add journal entry detail page at /bookkeeping/[id] with full entry view
- Add correction chain API and component showing storno relationships
- Add JournalEntryStatusBadge component for entry status display
- Show debit/credit account names in template picker and review dialogs
- Expand client-side BAS account name mapping with additional accounts
- Show account codes on transaction inbox suggestion buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — N+1 query, duplicate name, nav dedup

- Batch reverse-lookup into single query per BFS iteration (was N+1)
- Differentiate account 2393 from 2893 in display names
- Extract shared loop for desktop/mobile nav group rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup

- Add counterparty-based categorization templates (learned from user approvals
  and auto-ingestion) with fuzzy matching in the mapping engine
- Add Skatteverket extension for direct VAT declaration submission via API
- Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62)
- Fix ruta 49 formula to include import VAT (ruta 60+61+62)
- Simplify dashboard UI: remove redundant icons from stat cards, customer cards,
  invoice list, supplier invoices; use Badge variants consistently
- Add SkatteverketPanel component to reports page
- Add categorization_templates and skatteverket_tokens migrations
- Update tests and helpers for new types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback — VAT detection, migration timestamps, dedup

- Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line
  description instead of hardcoding standard_25
- Rename skatteverket_tokens migration to 20260324120001 to avoid
  duplicate timestamp with categorization_templates (fixes Supabase
  deployment failure)
- Make refreshAccessToken accept previousRefreshCount param to enforce
  refresh limit contract at the type level
- Fix rate limiter TOCTOU by claiming slot before await
- Extract formatRedovisare/formatRedovisningsperiod to shared
  lib/skatteverket/format.ts — eliminates duplication between
  mappers.ts and SkatteverketPanel.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: multi-line counterparty templates, batch matching, settings restructure

Counterparty template engine:
- Multi-line booking patterns (line_pattern JSONB) for complex entries
  with split VAT, tax accounts, and ratio-based allocation
- Batch matching (findCounterpartyTemplatesBatch) — 1 DB query for all
  transactions instead of up to 3 per transaction
- SIE voucher population (populateTemplatesFromSieVouchers) — extracts
  patterns from historical vouchers on import with dominance filtering
- Source priority system (user_approved > sie_import > auto_learned)
- Centralized counterparty: prefix helpers to prevent string fragility
- Fix: re-approval path now updates line_pattern

Transaction categorization:
- /describe route returns counterparty_match in parallel with templates/AI
- /categorize route accepts counterparty_template_id for direct booking
- /suggest-categories uses batch matching, injects counterparty suggestions
- transaction-entries supports all_lines_complete for multi-line patterns

UI:
- TemplatePicker shows "Tidigare motparter" section (no AI extension needed)
- DescribeTransactionDialog shows counterparty match card with detail
- QuickReviewDialog supports counterparty line patterns
- JournalEntryPreview renders multi-line patterns with VAT/ratio math
- Inline LinePatternEntry types replaced with shared import from @/types

Settings restructure:
- 8 tabs → 5: merged Säkerhet + Utseende + Kalender into Konto
- Renamed "Motparter" → "Mallar"
- CounterpartyTemplatesPanel: click-to-expand detail view with account
  lines, VAT, confidence, aliases, and delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — account_override guard, DELETE body parsing, stale test

- Block account_override when counterparty_template_id is set (prevents
  corrupting stored template via override → upsert correction path)
- Wrap DELETE request.json() in try-catch for malformed body (400 not 500)
- Clean up stale 3-query mock enqueues in test for batch-based find

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:54:53 +01:00

379 lines
12 KiB
TypeScript

import { suggestCategory } from '@/lib/tax/expense-warnings'
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { extensionRegistry } from '@/lib/extensions/registry'
import type { Transaction, TransactionCategory, EntityType, MappingRule, LinePatternEntry } from '@/types'
export interface SuggestedCategory {
category: TransactionCategory
label: string
account: string | null
confidence: number
source: 'mapping_rule' | 'pattern' | 'history' | 'ai'
match_reason?: string
}
const CATEGORY_LABELS: Record<string, string> = {
income_services: 'Tjänster',
income_products: 'Produkter',
income_other: 'Övriga intäkter',
expense_equipment: 'Utrustning',
expense_software: 'Programvara',
expense_travel: 'Resor',
expense_office: 'Kontor',
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',
expense_other: 'Övrigt',
}
/**
* Get suggested categories for a transaction
* Combines mapping rules, pattern matching, and user history
*/
export function getSuggestedCategories(
transaction: Transaction,
mappingRules: MappingRule[],
categoryHistory: Record<string, number>
): SuggestedCategory[] {
const suggestions: SuggestedCategory[] = []
const seen = new Set<string>()
// 1. Check mapping rules (highest confidence)
for (const rule of mappingRules) {
if (!rule.is_active) continue
let matches = false
if (rule.merchant_pattern && transaction.merchant_name) {
const pattern = new RegExp(rule.merchant_pattern, 'i')
if (pattern.test(transaction.merchant_name)) {
matches = true
}
}
if (rule.description_pattern) {
const pattern = new RegExp(rule.description_pattern, 'i')
if (pattern.test(transaction.description)) {
matches = true
}
}
if (rule.mcc_codes && transaction.mcc_code) {
if (rule.mcc_codes.includes(transaction.mcc_code)) {
matches = true
}
}
if (matches && rule.debit_account && !rule.default_private) {
// Reverse-lookup: find category from debit account
const category = accountToCategory(rule.debit_account, transaction.amount)
if (category && !seen.has(category)) {
seen.add(category)
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)
}
}
}
// 2. Pattern matching from expense-warnings
const patternMatch = suggestCategory(transaction.description)
if (patternMatch && !seen.has(patternMatch)) {
seen.add(patternMatch)
suggestions.push({
category: patternMatch as TransactionCategory,
label: CATEGORY_LABELS[patternMatch] || patternMatch,
account: getExpenseAccountForCategory(patternMatch as TransactionCategory),
confidence: 0.6,
source: 'pattern',
})
}
// 3. User history (most commonly used categories)
const historyEntries = Object.entries(categoryHistory)
.sort(([, a], [, b]) => b - a)
.filter(([cat]) => !seen.has(cat))
for (const [cat, count] of historyEntries) {
if (suggestions.length >= 4) break
// Only suggest relevant direction (expense for negative, income for positive)
if (transaction.amount < 0 && !cat.startsWith('expense_')) continue
if (transaction.amount > 0 && !cat.startsWith('income_')) continue
seen.add(cat)
suggestions.push({
category: cat as TransactionCategory,
label: CATEGORY_LABELS[cat] || cat,
account: getExpenseAccountForCategory(cat as TransactionCategory),
confidence: Math.min(0.5, count / 20),
source: 'history',
})
}
// Sort by confidence, limit to top 4
return suggestions
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 4)
}
/**
* Reverse-lookup: find category from BAS account number
*/
function accountToCategory(account: string, amount: number): string | null {
if (amount > 0) {
// Income
const incomeMap: Record<string, string> = {
'3001': 'income_services',
'3900': 'income_other',
}
return incomeMap[account] || 'income_other'
}
// Expense
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',
'7960': 'expense_currency_exchange',
}
return expenseMap[account] || null
}
/**
* Source priority for tiebreaking — used when two suggestions
* have the same confidence score.
*/
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 }[],
transactionAmount?: number
): SuggestedCategory[] {
const merged = [...existing]
for (const ai of aiSuggestions) {
// 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
}
const existingIdx = merged.findIndex((s) => s.category === ai.category)
if (existingIdx !== -1) {
// Upgrade existing entry if the AI has higher confidence
if (ai.confidence > merged[existingIdx].confidence) {
merged[existingIdx] = {
...merged[existingIdx],
account: ai.basAccount || merged[existingIdx].account,
confidence: ai.confidence,
source: 'ai',
}
}
continue
}
merged.push({
category: ai.category as TransactionCategory,
label: CATEGORY_LABELS[ai.category] || ai.category,
account: ai.basAccount || null,
confidence: ai.confidence,
source: 'ai',
})
}
// Sort by confidence first, then by source priority as tiebreaker
return merged
.sort((a, b) => {
const confidenceDiff = b.confidence - a.confidence
if (confidenceDiff !== 0) return confidenceDiff
return SOURCE_PRIORITY[b.source] - SOURCE_PRIORITY[a.source]
})
.slice(0, 5)
}
// ============================================================
// Template Suggestions
// ============================================================
export interface SuggestedTemplate {
template_id: string
name_sv: string
name_en: string
group: string
debit_account: string
credit_account: string
confidence: number
description_sv: string
risk_level: string
requires_review: boolean
line_pattern?: LinePatternEntry[] | null
}
/**
* Get recently used templates from mapping rules.
* Extracts unique template_id values and returns them as suggestions.
*/
export function getRecentlyUsedTemplates(
mappingRules: MappingRule[],
entityType?: EntityType,
direction?: 'expense' | 'income' | 'transfer'
): SuggestedTemplate[] {
const seen = new Set<string>()
const results: SuggestedTemplate[] = []
// Sort by most recent (highest priority first)
const sorted = [...mappingRules]
.filter((r) => r.is_active && r.template_id)
.sort((a, b) => (b.confidence_score || 0) - (a.confidence_score || 0))
for (const rule of sorted) {
if (!rule.template_id || seen.has(rule.template_id)) continue
seen.add(rule.template_id)
const template = getTemplateById(rule.template_id)
if (!template) continue
// Filter by entity applicability
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) continue
// Filter by direction
if (direction && template.direction !== direction && template.direction !== 'transfer') continue
results.push({
template_id: template.id,
name_sv: template.name_sv,
name_en: template.name_en,
group: template.group,
debit_account: template.debit_account,
credit_account: template.credit_account,
confidence: 0.85,
description_sv: template.description_sv,
risk_level: template.risk_level,
requires_review: template.requires_review,
})
if (results.length >= 5) break
}
return results
}
/**
* Get suggested booking templates for a transaction.
* Keyword matching as primary, AI embedding search as optional enhancer.
*/
export async function getSuggestedTemplates(
transaction: Transaction,
entityType?: EntityType,
mappingRules?: MappingRule[]
): Promise<SuggestedTemplate[]> {
const seen = new Set<string>()
const results: SuggestedTemplate[] = []
// 1. Boost recently-used templates from mapping rules
if (mappingRules) {
const direction = transaction.amount < 0 ? 'expense' : 'income'
const recent = getRecentlyUsedTemplates(mappingRules, entityType, direction)
for (const r of recent) {
if (!seen.has(r.template_id)) {
seen.add(r.template_id)
results.push(r)
}
}
}
// 2. Keyword + MCC matching (always available, no API keys needed)
const keywordMatches = findMatchingTemplates(transaction, entityType)
for (const m of keywordMatches) {
if (!seen.has(m.template.id)) {
seen.add(m.template.id)
results.push({
template_id: m.template.id,
name_sv: m.template.name_sv,
name_en: m.template.name_en,
group: m.template.group,
debit_account: m.template.debit_account,
credit_account: m.template.credit_account,
confidence: m.confidence,
description_sv: m.template.description_sv,
risk_level: m.template.risk_level,
requires_review: m.template.requires_review,
})
}
}
// 3. If AI extension loaded, merge in embedding-based matches (higher confidence)
try {
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.findSimilarTemplates) {
const aiMatches: TemplateMatch[] = await aiExt.services.findSimilarTemplates(transaction, entityType)
for (const m of aiMatches) {
const existing = results.find((r) => r.template_id === m.template.id)
if (existing) {
// AI match upgrades confidence if higher
if (m.confidence > existing.confidence) {
existing.confidence = m.confidence
}
} else {
results.push({
template_id: m.template.id,
name_sv: m.template.name_sv,
name_en: m.template.name_en,
group: m.template.group,
debit_account: m.template.debit_account,
credit_account: m.template.credit_account,
confidence: m.confidence,
description_sv: m.template.description_sv,
risk_level: m.template.risk_level,
requires_review: m.template.requires_review,
})
}
}
}
} catch {
// AI enhancement is non-blocking
}
return results
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 10)
}