* feat(mcp): book on custom accounts via account_override; fix kontoplan settings link gnubok_categorize_transaction only spoke a 19-category enum mapping to 21 hardcoded BAS accounts, so company-custom accounts (e.g. VMB) were unreachable from the agent surface even when active in the chart. - add account_override to gnubok_categorize_transaction with v1 REST semantics via a shared helper (lib/bookkeeping/account-override.ts): business-side replacement, class-2 auto-VAT drop with the 2610-2649 moms-line exception, plus a same-account degenerate guard; validated at staging and re-validated at commit - align the gnubok_create_voucher staging gate with the engine's seeding semantics: BAS 2026 accounts merely absent from the chart pass (the engine backfills them at commit) and the preview lists will_activate_accounts with BAS-name fallback; non-BAS unknown and inactive accounts still rejected - stop suggest_categories silently dropping mapping rules whose account is outside the fixed category maps; they surface with the rule's own account and an explanatory match_reason - correct the create_account next-step hint (categorize could never use the new account before; now true via account_override) - point the settings "Kontoplan (BAS)" link at /chart-of-accounts and redirect the orphaned /bookkeeping?tab=accounts URL (tab removed in #850; the deep link never worked after the #854 merge collision) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): address review findings on account_override - commit executor rejects a present-but-malformed stored account_override loudly instead of degrading to the category default (CodeRabbit major; the approver approved a preview showing the override account); with commitPendingOperation regression tests - accountToCategory returns null for unknown income accounts so custom income accounts get the same diagnostic as expenses (CodeRabbit minor), with income + reason-accumulation tests (CodeRabbit nit) - pin the class-2 VAT-drop balance invariant with a test through buildTransactionEntryLines (Swedish compliance review: gross booking, never an unbalanced net + missing VAT leg) - account_override description asks the agent to state the actual affärshändelse in notes when overriding (BFL 5 kap description concern) - eventBus.clear() in the two new test suites (CodeRabbit minor) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): never guess a moms leg onto an account_override without explicit VAT intent Round-2 Swedish compliance finding: the class-2 VAT drop did not cover margin-scheme (VMB) accounts in class 3/4, which are the override's flagship use case, so a forgotten vat_treatment attached the category default standard_25 and booked an ingående-moms deduction on a transaction where input VAT is not deductible (ML 2023:200). applyAccountOverride now takes explicit VAT intent (vat_treatment or vat_amount present) and books GROSS with no auto-VAT line without it: forgetting the flag under-deducts (lawful), never over-deducts. Both call sites (MCP staging preview, commit core) derive the flag the same way; the tool description states the enforced behavior. Deliberate divergence from v1 REST recorded in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: move stray decision-log entry to the root DECISIONS.md The round-2 entry was appended from the wrong working directory and landed as lib/bookkeeping/__tests__/DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
import { suggestCategory } from '@/lib/tax/expense-warnings'
|
|
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
|
import {
|
|
normalizeCounterpartyName,
|
|
formatCounterpartyName,
|
|
toCounterpartyTemplateId,
|
|
} from '@/lib/bookkeeping/counterparty-templates'
|
|
import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
|
import type {
|
|
Transaction,
|
|
TransactionCategory,
|
|
EntityType,
|
|
MappingRule,
|
|
LinePatternEntry,
|
|
VatTreatment,
|
|
CategorizationTemplate,
|
|
} from '@/types'
|
|
|
|
export interface SuggestedCategory {
|
|
category: TransactionCategory
|
|
label: string
|
|
account: string | null
|
|
confidence: number
|
|
source: 'mapping_rule' | 'pattern' | 'history'
|
|
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',
|
|
}
|
|
|
|
/**
|
|
* Counterparty-keyed history: normalized merchant name -> category counts.
|
|
* Built once per request from the caller's recent categorized transactions.
|
|
*/
|
|
export type MerchantHistoryMap = Map<string, Record<string, number>>
|
|
|
|
/**
|
|
* History keys share the counterparty-template normalization so card
|
|
* descriptors ("ANTHROPIC* CLAUDE SUB SAN FRANCISCO") and clean merchant
|
|
* names ("Anthropic") aggregate under one key. merchant_name is null on card
|
|
* purchases (bank feeds only carry counterparty names for transfers), so the
|
|
* descriptor is the fallback identity: without it, card merchants have no
|
|
* history at all and every recurring foreign SaaS line reads as no-signal.
|
|
* Callers pass `original_description ?? description`: the raw bank descriptor
|
|
* is the stable anchor, `description` is a user-editable working title that
|
|
* would sever the link on rename.
|
|
*/
|
|
function normalizeMerchantKey(
|
|
merchantName: string | null | undefined,
|
|
descriptor?: string | null,
|
|
): string {
|
|
const raw = (merchantName ?? '').trim() || (descriptor ?? '').trim()
|
|
return raw ? normalizeCounterpartyName(raw) : ''
|
|
}
|
|
|
|
export function buildMerchantHistory(
|
|
rows: Array<{
|
|
merchant_name: string | null
|
|
description?: string | null
|
|
original_description?: string | null
|
|
category: string | null
|
|
}>,
|
|
): MerchantHistoryMap {
|
|
const map: MerchantHistoryMap = new Map()
|
|
for (const row of rows) {
|
|
const key = normalizeMerchantKey(
|
|
row.merchant_name,
|
|
row.original_description ?? row.description,
|
|
)
|
|
if (!key || !row.category) continue
|
|
const bucket = map.get(key) ?? {}
|
|
bucket[row.category] = (bucket[row.category] || 0) + 1
|
|
map.set(key, bucket)
|
|
}
|
|
return map
|
|
}
|
|
|
|
export function merchantHistoryFor(
|
|
map: MerchantHistoryMap,
|
|
merchantName: string | null | undefined,
|
|
descriptor?: string | null,
|
|
): Record<string, number> {
|
|
const key = normalizeMerchantKey(merchantName, descriptor)
|
|
return key ? (map.get(key) ?? {}) : {}
|
|
}
|
|
|
|
/**
|
|
* Get suggested categories for a transaction.
|
|
* Combines mapping rules, pattern matching, and counterparty history.
|
|
*
|
|
* merchantHistory is the category history FOR THIS TRANSACTION'S counterparty
|
|
* (see buildMerchantHistory/merchantHistoryFor): never a company-wide
|
|
* frequency map. Global padding produced identical ~0.5 four-way spreads on
|
|
* every transaction, which agents correctly read as no signal
|
|
* (mcp_optimization_plan P2-1); an empty result is the honest answer.
|
|
*/
|
|
export function getSuggestedCategories(
|
|
transaction: Transaction,
|
|
mappingRules: MappingRule[],
|
|
merchantHistory: 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. A rule booking on
|
|
// an account outside the fixed maps (company-custom accounts like VMB)
|
|
// must still surface: the account itself is the signal, and callers
|
|
// reach it via account_override. Fall back to the direction's generic
|
|
// category instead of silently dropping the rule.
|
|
const mapped = accountToCategory(rule.debit_account, transaction.amount)
|
|
const category = mapped ?? (transaction.amount < 0 ? 'expense_other' : 'income_other')
|
|
if (!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',
|
|
}
|
|
const reasons: string[] = []
|
|
if (rule.source === 'user_description' && rule.user_description) {
|
|
reasons.push(`Matchad på din beskrivning: ${rule.user_description}`)
|
|
}
|
|
if (!mapped) {
|
|
reasons.push(`Regeln bokför på konto ${rule.debit_account} (utanför standardkategorierna)`)
|
|
}
|
|
if (reasons.length > 0) {
|
|
suggestion.match_reason = reasons.join('. ')
|
|
}
|
|
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. Counterparty history: categories this merchant was booked as before.
|
|
// Confidence scales with occurrences and the reason carries provenance.
|
|
const historyEntries = Object.entries(merchantHistory)
|
|
.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),
|
|
// 1 previous booking -> 0.56, capped at 0.85 (history informs, a human
|
|
// or counterparty template confirms).
|
|
confidence: Math.min(0.85, 0.5 + count * 0.06),
|
|
source: 'history',
|
|
match_reason: `Bokförd ${count} gång${count === 1 ? '' : 'er'} tidigare för denna motpart`,
|
|
})
|
|
}
|
|
|
|
// 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. Unknown accounts return null (not a blanket 'income_other') so
|
|
// the caller can tell a mapped account from a custom one and attach the
|
|
// custom-account diagnostic; the caller's fallback still lands on
|
|
// income_other, so the surfaced category is unchanged.
|
|
const incomeMap: Record<string, string> = {
|
|
'3001': 'income_services',
|
|
'3900': 'income_other',
|
|
}
|
|
return incomeMap[account] || null
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ============================================================
|
|
// 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
|
|
// Learned VAT treatment on a single-line counterparty suggestion. Without
|
|
// it the review dialog previews the verifikation at gross with no moms leg,
|
|
// while the server books the expense net + 2641. Multi-line suggestions
|
|
// carry their VAT inside line_pattern instead.
|
|
vat_treatment?: VatTreatment | null
|
|
// Learned {sie_dim_no: code} bag on counterparty suggestions: prefills the
|
|
// review dialog's dimension picker (the server applies it at booking anyway;
|
|
// surfacing it keeps the user in the loop).
|
|
default_dimensions?: Record<string, string> | 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,
|
|
})
|
|
}
|
|
}
|
|
|
|
return results
|
|
.sort((a, b) => b.confidence - a.confidence)
|
|
.slice(0, 10)
|
|
}
|
|
|
|
/**
|
|
* Shape a learned counterparty template into the suggestion the transaction
|
|
* modal renders under "Tidigare motparter".
|
|
*
|
|
* Every field the review dialog later reads has to come across here: the
|
|
* dialog books through `counterparty_template_id`, so the accounts and VAT it
|
|
* previews must be the template's own, not the transaction category's
|
|
* fallbacks. A suggestion that omitted them previously left the dialog with an
|
|
* undefined default account, which crashed the page.
|
|
*/
|
|
export function buildCounterpartySuggestion(
|
|
template: CategorizationTemplate,
|
|
confidence: number,
|
|
): SuggestedTemplate {
|
|
return {
|
|
template_id: toCounterpartyTemplateId(template.id),
|
|
name_sv: formatCounterpartyName(template.counterparty_name),
|
|
name_en: formatCounterpartyName(template.counterparty_name),
|
|
group: 'counterparty',
|
|
debit_account: template.debit_account,
|
|
credit_account: template.credit_account,
|
|
confidence,
|
|
description_sv: `${template.occurrence_count} tidigare bokföringar`,
|
|
risk_level: 'NONE',
|
|
requires_review: false,
|
|
line_pattern: template.line_pattern ?? null,
|
|
// Single-line templates book net expense + input VAT from this treatment
|
|
// (buildMappingResultFromCounterpartyTemplate); the review dialog needs it
|
|
// to preview the same verifikation.
|
|
vat_treatment: template.vat_treatment ?? null,
|
|
default_dimensions:
|
|
template.default_dimensions && Object.keys(template.default_dimensions).length > 0
|
|
? template.default_dimensions
|
|
: null,
|
|
}
|
|
}
|