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,162 @@
|
||||
/**
|
||||
* applyAccountOverride: the shared override semantics behind the v1 REST
|
||||
* account_override and the MCP gnubok_categorize_transaction parameter.
|
||||
* The override replaces the business side of a category-derived mapping;
|
||||
* these tests pin side selection, chart validation, the class-2 VAT drop
|
||||
* (with the 2610-2649 moms-line exception), and the degenerate same-account
|
||||
* guard.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { applyAccountOverride } from '../account-override'
|
||||
import { buildMappingResultFromCategory } from '../category-mapping'
|
||||
import { buildTransactionEntryLines } from '../transaction-entries'
|
||||
import type { MappingResult, Transaction } from '@/types'
|
||||
|
||||
const mapping = (over: Partial<MappingResult> = {}): MappingResult =>
|
||||
({
|
||||
rule: null,
|
||||
debit_account: '6991',
|
||||
credit_account: '1930',
|
||||
risk_level: 'LOW',
|
||||
confidence: 1.0,
|
||||
requires_review: false,
|
||||
default_private: false,
|
||||
vat_lines: [
|
||||
{ account_number: '2641', debit_amount: 100, credit_amount: 0, description: 'Ingående moms 25%' },
|
||||
],
|
||||
description: 'Övrig kostnad: test',
|
||||
...over,
|
||||
}) as MappingResult
|
||||
|
||||
const chartRow = (over: Record<string, unknown> = {}) => ({
|
||||
account_number: '4020',
|
||||
account_class: 4,
|
||||
is_active: true,
|
||||
...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('applyAccountOverride', () => {
|
||||
it('replaces the DEBIT side when money goes out (amount < 0)', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: chartRow() })
|
||||
|
||||
const result = await applyAccountOverride(supabase as never, 'company-1', '4020', -479, mapping(), true)
|
||||
|
||||
expect(result.debit_account).toBe('4020')
|
||||
expect(result.credit_account).toBe('1930')
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('replaces the CREDIT side when money comes in (amount > 0)', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: chartRow({ account_number: '3021', account_class: 3 }) })
|
||||
|
||||
const result = await applyAccountOverride(
|
||||
supabase as never, 'company-1', '3021', 479,
|
||||
mapping({ debit_account: '1930', credit_account: '3001' }), true,
|
||||
)
|
||||
|
||||
expect(result.debit_account).toBe('1930')
|
||||
expect(result.credit_account).toBe('3021')
|
||||
})
|
||||
|
||||
it('throws with an actionable message when the account is not in the chart', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null })
|
||||
|
||||
await expect(
|
||||
applyAccountOverride(supabase as never, 'company-1', '4020', -479, mapping(), true),
|
||||
).rejects.toThrow(/finns inte i kontoplanen/)
|
||||
})
|
||||
|
||||
it('throws with an activation hint when the account exists but is inactive', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: chartRow({ is_active: false }) })
|
||||
|
||||
await expect(
|
||||
applyAccountOverride(supabase as never, 'company-1', '4020', -479, mapping(), true),
|
||||
).rejects.toThrow(/inaktivt/)
|
||||
})
|
||||
|
||||
it('drops auto-VAT lines for a class-2 override outside the moms-line range', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: chartRow({ account_number: '2894', account_class: 2 }) })
|
||||
|
||||
const result = await applyAccountOverride(supabase as never, 'company-1', '2894', -479, mapping(), true)
|
||||
|
||||
expect(result.debit_account).toBe('2894')
|
||||
expect(result.vat_lines).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps auto-VAT lines for a class-2 override inside 2610-2649 (moms-line accounts)', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: chartRow({ account_number: '2641', account_class: 2 }) })
|
||||
|
||||
const result = await applyAccountOverride(supabase as never, 'company-1', '2641', -479, mapping(), true)
|
||||
|
||||
expect(result.debit_account).toBe('2641')
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops auto-VAT for ANY override without explicit VAT intent (VMB class-3/4 hole)', async () => {
|
||||
// Swedish compliance review finding: VMB accounts live in class 3/4, so
|
||||
// the class-2 drop alone let a forgotten vat_treatment attach the
|
||||
// category-default standard_25 moms leg to a margin-scheme account: an
|
||||
// ingående-moms deduction the caller never asked for (ML 2023:200).
|
||||
// Without explicit VAT intent the override must book gross: forgetting
|
||||
// the flag under-deducts (lawful), never over-deducts.
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: chartRow() }) // 4020, class 4
|
||||
|
||||
const result = await applyAccountOverride(supabase as never, 'company-1', '4020', -479, mapping(), false)
|
||||
|
||||
expect(result.debit_account).toBe('4020')
|
||||
expect(result.vat_lines).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the entry balanced at GROSS when a class-2 override clears the VAT lines', async () => {
|
||||
// The Swedish compliance review asked for this invariant explicitly: the
|
||||
// business-line amount is derived from vat_lines inside
|
||||
// buildTransactionEntryLines, so clearing them books gross, never an
|
||||
// unbalanced net + missing VAT leg (BFL 5 kap balanced-entry requirement).
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: chartRow({ account_number: '2894', account_class: 2 }) })
|
||||
|
||||
const tx = {
|
||||
id: 'tx-1',
|
||||
company_id: 'company-1',
|
||||
date: '2026-07-10',
|
||||
amount: -479,
|
||||
currency: 'SEK',
|
||||
description: 'Second hand',
|
||||
} as Transaction
|
||||
|
||||
let mr = buildMappingResultFromCategory('expense_other', tx, true, 'aktiebolag', 'standard_25')
|
||||
expect(mr.vat_lines).toHaveLength(1)
|
||||
mr = await applyAccountOverride(supabase as never, 'company-1', '2894', tx.amount, mr, true)
|
||||
|
||||
const lines = buildTransactionEntryLines(tx, mr)
|
||||
const totalDebit = lines.reduce((s, l) => s + (l.debit_amount ?? 0), 0)
|
||||
const totalCredit = lines.reduce((s, l) => s + (l.credit_amount ?? 0), 0)
|
||||
expect(totalDebit).toBe(479)
|
||||
expect(totalCredit).toBe(479)
|
||||
expect(lines.find((l) => l.account_number === '2894')?.debit_amount).toBe(479)
|
||||
expect(lines.some((l) => l.account_number === '2641')).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a degenerate entry where both sides land on the same account', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: chartRow({ account_number: '1930', account_class: 1 }) })
|
||||
|
||||
await expect(
|
||||
applyAccountOverride(supabase as never, 'company-1', '1930', -479, mapping(), true),
|
||||
).rejects.toThrow(/samma konto/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { MappingResult } from '@/types'
|
||||
|
||||
/**
|
||||
* Apply an explicit account override to a category-derived MappingResult.
|
||||
*
|
||||
* Same semantics as the v1 REST categorize route (app/api/v1/.../categorize):
|
||||
* the override replaces the business side of the mapping (debit when money
|
||||
* goes out, credit when money comes in) AFTER the settlement account has been
|
||||
* applied, so callers can book on company-custom accounts (e.g. VMB accounts)
|
||||
* that the fixed category → account maps cannot reach.
|
||||
*
|
||||
* The account must exist AND be active in the company's chart_of_accounts.
|
||||
* Unlike voucher lines, an override is never BAS-backfilled: the category
|
||||
* mapping's own account is the safe default when the override is wrong, so an
|
||||
* unknown number is a caller error, not a seeding opportunity.
|
||||
*
|
||||
* VAT lines survive an override only when the caller stated its VAT intent
|
||||
* explicitly (`vatExplicit`: a vat_treatment or vat_amount was passed).
|
||||
* Without it the override books GROSS with no auto-VAT line: the category
|
||||
* default (standard_25) was derived for the category's default account, and
|
||||
* carrying it onto an arbitrary override account fabricates a moms deduction
|
||||
* the caller never asked for. The flagship case is margin-scheme (VMB)
|
||||
* accounts in class 3/4, where input VAT is not deductible at all
|
||||
* (ML 2023:200): forgetting the treatment must under-deduct, never
|
||||
* over-deduct. Overrides onto a balance-sheet class 2 account drop the
|
||||
* auto-VAT lines even when explicit, EXCEPT the moms-line range 2610-2649
|
||||
* where posting VAT is the point (2650 momsredovisningskonto and 2690
|
||||
* diverse are class 2 but not moms-line accounts; auto-VAT there would
|
||||
* double-post).
|
||||
*
|
||||
* Throws on unknown/inactive account or a degenerate same-account entry; the
|
||||
* message is Swedish and actionable for both the agent and the approval UI.
|
||||
*/
|
||||
export async function applyAccountOverride(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountOverride: string,
|
||||
transactionAmount: number,
|
||||
mappingResult: MappingResult,
|
||||
vatExplicit: boolean,
|
||||
): Promise<MappingResult> {
|
||||
const { data: account, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_class, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', accountOverride)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Database error: ${error.message}`)
|
||||
}
|
||||
if (!account) {
|
||||
throw new Error(
|
||||
`Konto ${accountOverride} finns inte i kontoplanen: account_override kräver ett befintligt aktivt konto. ` +
|
||||
'Skapa det först (gnubok_create_account) eller välj ett annat konto.',
|
||||
)
|
||||
}
|
||||
if (!account.is_active) {
|
||||
throw new Error(
|
||||
`Konto ${accountOverride} är inaktivt i kontoplanen. ` +
|
||||
'Aktivera det först (gnubok_update_account med is_active=true) eller välj ett annat konto.',
|
||||
)
|
||||
}
|
||||
|
||||
if (transactionAmount < 0) {
|
||||
mappingResult.debit_account = accountOverride
|
||||
} else {
|
||||
mappingResult.credit_account = accountOverride
|
||||
}
|
||||
|
||||
if (mappingResult.debit_account === mappingResult.credit_account) {
|
||||
throw new Error(
|
||||
`account_override ${accountOverride} är samma konto som motkontot: ` +
|
||||
'verifikatet skulle debitera och kreditera samma konto. Välj ett annat konto.',
|
||||
)
|
||||
}
|
||||
|
||||
const overrideNum = parseInt(accountOverride, 10)
|
||||
const isMomsLineAccount = overrideNum >= 2610 && overrideNum <= 2649
|
||||
if (!vatExplicit || (account.account_class === 2 && !isMomsLineAccount)) {
|
||||
mappingResult.vat_lines = []
|
||||
}
|
||||
|
||||
return mappingResult
|
||||
}
|
||||
Reference in New Issue
Block a user