* 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>
208 lines
7.6 KiB
TypeScript
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([])
|
|
})
|
|
})
|