Files
accounted/lib/transactions/__tests__/category-suggestions.test.ts
T
MattssonandClaude Fable 5 fbe4e18730 feat(mcp): book on custom accounts via account_override; fix kontoplan settings link (#1608)
* 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>
2026-08-14 01:24:48 +02:00

208 lines
7.6 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildMerchantHistory,
getSuggestedCategories,
merchantHistoryFor,
} from '../category-suggestions'
import type { Transaction } from '@/types'
/**
* P2-1 (mcp_optimization_plan): suggestions must carry signal tied to THIS
* transaction. The old company-wide frequency fallback emitted an identical
* ~0.5 four-way spread on every transaction: noise agents correctly
* distrusted. History is now counterparty-keyed with provenance; when no
* source matches, the honest answer is an empty list.
*/
const tx = (overrides: Partial<Transaction> = {}): Transaction =>
({
id: 'tx-1',
company_id: 'company-1',
date: '2026-06-01',
description: 'KORTKÖP POLARN O PYRET',
amount: -500,
currency: 'SEK',
merchant_name: 'Polarn O. Pyret',
...overrides,
}) as Transaction
describe('buildMerchantHistory / merchantHistoryFor', () => {
const rows = [
{ merchant_name: 'Polarn O. Pyret', category: 'expense_office' },
{ merchant_name: 'polarn o. pyret', category: 'expense_office' },
{ merchant_name: 'Polarn O. Pyret', category: 'expense_consumables' },
{ merchant_name: 'DNB Bank', category: 'expense_bank_fees' },
{ merchant_name: null, category: 'expense_other' },
{ merchant_name: 'Ghost AB', category: null },
]
it('groups case-insensitively by merchant and ignores null merchants/categories', () => {
const map = buildMerchantHistory(rows)
expect(merchantHistoryFor(map, 'POLARN O. PYRET')).toEqual({
expense_office: 2,
expense_consumables: 1,
})
expect(merchantHistoryFor(map, 'DNB Bank')).toEqual({ expense_bank_fees: 1 })
expect(merchantHistoryFor(map, 'Unknown Vendor')).toEqual({})
expect(merchantHistoryFor(map, null)).toEqual({})
})
it('falls back to the description when merchant_name is null (card purchases)', () => {
// Bank feeds only carry counterparty names for transfers; card purchases
// arrive with merchant_name null and the merchant buried in a descriptor
// whose tail (product, city) changes between charges. All of these are
// one counterparty: the reported Anthropic no-signal bug.
const map = buildMerchantHistory([
{ merchant_name: null, description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO', category: 'expense_software' },
{ merchant_name: null, description: 'ANTHROPIC*CLAUDE SUB +14155551234', category: 'expense_software' },
{ merchant_name: 'Anthropic', description: 'irrelevant when merchant_name set', category: 'expense_software' },
])
expect(merchantHistoryFor(map, null, 'ANTHROPIC* CLAUDE SUB LONDON')).toEqual({
expense_software: 3,
})
expect(merchantHistoryFor(map, 'Anthropic')).toEqual({ expense_software: 3 })
})
it('anchors on original_description so user renames do not sever history', () => {
// description is a mutable working title; a user renaming the row to
// "Software" must not detach it from the raw bank descriptor identity.
const map = buildMerchantHistory([
{
merchant_name: null,
description: 'Software',
original_description: 'ANTHROPIC* CLAUDE SUB SAN FRANCISCO',
category: 'expense_software',
},
])
expect(merchantHistoryFor(map, null, 'ANTHROPIC*CLAUDE SUB +14155551234')).toEqual({
expense_software: 1,
})
// Renamed title itself is NOT a key when the raw descriptor exists.
expect(merchantHistoryFor(map, null, 'Software')).toEqual({})
})
})
describe('getSuggestedCategories: mapping rules on custom accounts', () => {
const rule = (over: Record<string, unknown> = {}) =>
({
id: 'rule-1',
company_id: 'company-1',
is_active: true,
merchant_pattern: 'Myrorna',
description_pattern: null,
mcc_codes: null,
debit_account: '4020',
credit_account: '1930',
default_private: false,
confidence_score: 0.9,
priority: 10,
source: 'user',
user_description: null,
...over,
// MappingRule carries many more columns; only the fields the suggestion
// engine reads are modelled here.
}) as never
it('surfaces a rule booking on a custom account instead of silently dropping it', async () => {
const result = getSuggestedCategories(
tx({ merchant_name: 'Myrorna', description: 'MYRORNA BUTIK 1' }),
[rule()],
{},
)
// 4020 is outside the fixed category maps: the old reverse-lookup
// returned null and the rule vanished with no diagnostic.
expect(result.length).toBe(1)
expect(result[0]).toMatchObject({
category: 'expense_other',
account: '4020',
source: 'mapping_rule',
confidence: 0.9,
})
expect(result[0].match_reason).toMatch(/konto 4020/)
})
it('surfaces an unmapped INCOME account with the custom-account diagnostic', async () => {
const result = getSuggestedCategories(
tx({ amount: 100, merchant_name: 'Myrorna', description: 'SWISH MYRORNA' }),
[rule({ debit_account: '3020' })],
{},
)
expect(result.length).toBe(1)
expect(result[0]).toMatchObject({
category: 'income_other',
account: '3020',
source: 'mapping_rule',
})
expect(result[0].match_reason).toMatch(/konto 3020/)
})
it('accumulates the user_description reason with the custom-account reason', async () => {
const result = getSuggestedCategories(
tx({ merchant_name: 'Myrorna', description: 'MYRORNA BUTIK 1' }),
[rule({ source: 'user_description', user_description: 'Second hand-inköp till butiken' })],
{},
)
expect(result.length).toBe(1)
expect(result[0].match_reason).toMatch(/Matchad på din beskrivning: Second hand-inköp till butiken/)
expect(result[0].match_reason).toMatch(/konto 4020/)
})
it('keeps the exact category for rules on accounts inside the fixed maps', async () => {
const result = getSuggestedCategories(
tx({ merchant_name: 'Anthropic', description: 'ANTHROPIC* CLAUDE' }),
[rule({ merchant_pattern: 'Anthropic', debit_account: '5420' })],
{},
)
expect(result.length).toBe(1)
expect(result[0]).toMatchObject({
category: 'expense_software',
account: '5420',
source: 'mapping_rule',
})
expect(result[0].match_reason).toBeUndefined()
})
})
describe('getSuggestedCategories: counterparty history', () => {
it('returns an empty list (not a fabricated spread) when nothing matches', () => {
const result = getSuggestedCategories(
tx({ merchant_name: 'Helt Okänd Motpart', description: 'XYZ 123' }),
[],
{},
)
expect(result).toEqual([])
})
it('surfaces merchant history with provenance and occurrence-scaled confidence', () => {
const result = getSuggestedCategories(tx({ description: 'XYZ 123' }), [], {
expense_office: 3,
expense_consumables: 1,
})
expect(result.length).toBe(2)
expect(result[0]).toMatchObject({
category: 'expense_office',
source: 'history',
confidence: Math.min(0.85, 0.5 + 3 * 0.06),
})
expect(result[0].match_reason).toMatch(/3 gånger tidigare för denna motpart/)
expect(result[1].category).toBe('expense_consumables')
expect(result[1].match_reason).toMatch(/1 gång tidigare/)
})
it('caps history confidence at 0.85', () => {
const result = getSuggestedCategories(tx({ description: 'XYZ 123' }), [], {
expense_office: 50,
})
expect(result[0].confidence).toBe(0.85)
})
it('filters history to the transaction direction', () => {
const result = getSuggestedCategories(
tx({ amount: 1000, description: 'XYZ 123' }), // income direction
[],
{ expense_office: 5 },
)
expect(result).toEqual([])
})
})