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:
co-authored by
Claude Fable 5
parent
4bb0655e4a
commit
fbe4e18730
@@ -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(
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { applyAccountOverride } from '@/lib/bookkeeping/account-override'
|
||||
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
@@ -72,6 +73,14 @@ export interface CategorizeMatchedTransactionOpts {
|
||||
* registry at staging time (MCP) or picked in the UI.
|
||||
*/
|
||||
dimensions?: Record<string, string>
|
||||
/**
|
||||
* Explicit business-side account (e.g. a company-custom VMB account) that
|
||||
* replaces the category's debit (money out) or credit (money in) account,
|
||||
* with the same semantics as the v1 REST route's account_override: must be
|
||||
* present and active in chart_of_accounts, never combined with category
|
||||
* 'private'. See lib/bookkeeping/account-override.ts.
|
||||
*/
|
||||
accountOverride?: string
|
||||
}
|
||||
|
||||
// ── Helper: duplicate-guard claim text ───────────────────────────────
|
||||
@@ -211,7 +220,7 @@ export async function categorizeMatchedTransaction(
|
||||
*/
|
||||
exclude?: BookingDuplicateExclusions,
|
||||
): Promise<CategorizeCoreResult> {
|
||||
const { category, vatTreatment, vatAmount, notes, allowDuplicate, dimensions } = opts
|
||||
const { category, vatTreatment, vatAmount, notes, allowDuplicate, dimensions, accountOverride } = opts
|
||||
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
|
||||
@@ -342,6 +351,24 @@ export async function categorizeMatchedTransaction(
|
||||
log,
|
||||
)
|
||||
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
|
||||
// Re-validated here (not only at staging): the account can be deactivated
|
||||
// between MCP staging and the user's approval, and the posted entry must
|
||||
// never land on an account the chart no longer offers.
|
||||
if (accountOverride) {
|
||||
if (!isBusiness) {
|
||||
return { error: 'account_override kan inte kombineras med category "private".', status: 400 }
|
||||
}
|
||||
try {
|
||||
mappingResult = await applyAccountOverride(
|
||||
supabase, companyId, accountOverride, transaction.amount, mappingResult,
|
||||
// Explicit VAT intent: a stated treatment or an underlag vat_amount.
|
||||
// Without it the override books gross (see applyAccountOverride).
|
||||
vatTreatment != null || vatAmount != null,
|
||||
)
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'account_override failed', status: 400 }
|
||||
}
|
||||
}
|
||||
// Dimensions PR7: tag the business lines of the generated verifikat.
|
||||
if (dimensions && Object.keys(dimensions).length > 0) {
|
||||
mappingResult.dimensions = dimensions
|
||||
|
||||
@@ -147,9 +147,14 @@ export function getSuggestedCategories(
|
||||
}
|
||||
|
||||
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)) {
|
||||
// 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,
|
||||
@@ -158,8 +163,15 @@ export function getSuggestedCategories(
|
||||
confidence: rule.confidence_score || 0.8,
|
||||
source: 'mapping_rule',
|
||||
}
|
||||
const reasons: string[] = []
|
||||
if (rule.source === 'user_description' && rule.user_description) {
|
||||
suggestion.match_reason = `Matchad på din beskrivning: ${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)
|
||||
}
|
||||
@@ -215,12 +227,15 @@ export function getSuggestedCategories(
|
||||
*/
|
||||
function accountToCategory(account: string, amount: number): string | null {
|
||||
if (amount > 0) {
|
||||
// Income
|
||||
// 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] || 'income_other'
|
||||
return incomeMap[account] || null
|
||||
}
|
||||
|
||||
// Expense
|
||||
|
||||
Reference in New Issue
Block a user