Files
accounted/lib/transactions/category-suggestions.ts
T
Jakob Wennberg 198d3092c7 fix: counterparty template pick crashes the page (#1291)
Picking a suggestion under "Tidigare motparter" in Bokför transaktion replaced
the page with "Något gick fel". handleOpenTemplateReview built the review state
from `{ id, name_sv } as BookingTemplate`, so `template.debit_account` was
undefined, reached QuickReviewDialog's required `defaultAccount: string`, and
threw on `accountOverride.startsWith('2')` during the first render.

Typed the dialog's template prop as a narrow ReviewTemplate whose optional
fields are actually optional, so the cast disappears and the compiler owns this
class of bug. Also carries the counterparty's learned accounts and VAT (the
preview showed the category fallback, not what the server books) and decides
"is this a counterparty booking" from the template id rather than the presence
of a line_pattern (single-line templates got an account/VAT editor the
categorize route discards).

Five more page-crashes of the same shape, adversarially verified:

- suppliers/[id] and supplier-invoices/[id] passed the error envelope OBJECT as
  a toast description. The Toaster is a sibling of {children} in the ROOT
  layout, so that throw escapes both segment error boundaries onto global-error.
- components/reports/views wrote the same object into a useState<string | null>
  at 13 sites and rendered it bare.
- components/ui/toaster.tsx now coerces non-renderable values as a choke point.
- skattekonto read data.informationstext.length off Skatteverket's raw JSON,
  where the field is not required.
- TicWorkspace read profile.statuses.length off a persisted jsonb blob. 17 of 17
  prod rows predate the TIC v2 upgrade (#584) and lack the key, so that
  workspace was in the error boundary for every company that had opened it.

Plus hardening: formatCurrency coerces a null currency to SEK (prod has 0 NULL
across 28 416 transactions, so defense not a live bug) and cleanSignatory
returns [] for a missing description.

Verified by rendering the real dialog against a throwaway /sandbox route: the
pre-fix prop shape reproduces the exact error boundary, the fixed one renders
D: 6570 Bankavgifter / K: 1930 Företagskonto and the matching verifikat.

No migrations.
2026-07-29 19:20:25 +02:00

407 lines
14 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
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. 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
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
}
// ============================================================
// 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,
}
}