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>
This commit is contained in:
Mattsson
2026-08-14 01:24:48 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4bb0655e4a
commit fbe4e18730
14 changed files with 915 additions and 24 deletions
@@ -0,0 +1,141 @@
/**
* categorizeMatchedTransaction: the accountOverride commit path.
*
* The MCP staging tool validates the override once, but the account can be
* deactivated between staging and the user's approval, so the core re-applies
* and re-validates independently. These tests pin that the posted mapping
* carries the override account, and that a stale override degrades to a
* structured 400 (never a posted entry on a dead account).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const mockCreateJE = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
}))
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/transactions/link-journal-entry', () => ({
hasLiveJournalEntryLink: vi.fn().mockResolvedValue(false),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue(undefined),
}))
import { categorizeMatchedTransaction } from '../categorize-core'
const TX_ID = '00000000-0000-4000-8000-0000000000cc'
const txRow = (over: Record<string, unknown> = {}) => ({
id: TX_ID,
company_id: 'company-1',
date: '2026-07-10',
amount: -479,
currency: 'SEK',
amount_sek: -479,
exchange_rate: 1,
description: 'SECOND HAND BUTIK',
merchant_name: null,
cash_account_id: null,
document_id: null,
journal_entry_id: null,
...over,
})
const settingsRow = { entity_type: 'aktiebolag', fiscal_year_start_month: 1 }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockCreateJE.mockResolvedValue({ id: 'je-override-1' })
})
describe('categorizeMatchedTransaction: accountOverride', () => {
it('posts the entry with the override on the business side', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() }) // transactions select
enqueue({ data: settingsRow }) // company_settings
enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } }) // override chart hit
enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod: open period exists
enqueue({ data: null }) // transactions update
const result = await categorizeMatchedTransaction(
supabase as never, 'user-1', 'company-1', TX_ID,
{ category: 'expense_other', vatTreatment: 'exempt', accountOverride: '4020' },
)
expect(result.error).toBeUndefined()
expect(result.data?.journal_entry_id).toBe('je-override-1')
// 5th arg of createTransactionJournalEntry is the mapping result.
const mappingArg = mockCreateJE.mock.calls[0][4] as {
debit_account: string
credit_account: string
}
expect(mappingArg.debit_account).toBe('4020')
expect(mappingArg.credit_account).toBe('1930')
})
it('books GROSS with no auto-VAT line when the override has no explicit VAT intent', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: settingsRow })
enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } })
enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod
enqueue({ data: null }) // transactions update
const result = await categorizeMatchedTransaction(
supabase as never, 'user-1', 'company-1', TX_ID,
// No vatTreatment and no vatAmount: the category default standard_25
// must NOT ride along onto the custom account.
{ category: 'expense_other', accountOverride: '4020' },
)
expect(result.error).toBeUndefined()
const mappingArg = mockCreateJE.mock.calls[0][4] as {
debit_account: string
vat_lines: unknown[]
}
expect(mappingArg.debit_account).toBe('4020')
expect(mappingArg.vat_lines).toEqual([])
})
it('returns 400 (never posts) when the override was deactivated after staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: settingsRow })
enqueue({ data: { account_number: '4020', account_class: 4, is_active: false } })
const result = await categorizeMatchedTransaction(
supabase as never, 'user-1', 'company-1', TX_ID,
{ category: 'expense_other', vatTreatment: 'exempt', accountOverride: '4020' },
)
expect(result.status).toBe(400)
expect(result.error).toMatch(/inaktivt/)
expect(mockCreateJE).not.toHaveBeenCalled()
})
it('returns 400 when accountOverride is combined with category "private"', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: settingsRow })
const result = await categorizeMatchedTransaction(
supabase as never, 'user-1', 'company-1', TX_ID,
{ category: 'private', accountOverride: '4020' },
)
expect(result.status).toBe(400)
expect(result.error).toMatch(/private/)
expect(mockCreateJE).not.toHaveBeenCalled()
})
})
@@ -82,6 +82,87 @@ describe('buildMerchantHistory / merchantHistoryFor', () => {
})
})
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(