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,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/)
})
})
+86
View File
@@ -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
}
@@ -1364,3 +1364,70 @@ describe('commitPendingOperation: categorize_transaction: dimensions propagation
expect(opts.dimensions).toBeUndefined()
})
})
// ─── categorize_transaction: account_override tamper gate ───────────────────
describe('commitPendingOperation: categorize_transaction account_override', () => {
it('rejects loudly when a stored override is present but malformed (tamper/drift)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's rejected update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: '40a0' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
// Never degrade to the category default: the approver approved a preview
// showing the override account. 400 lands as 'failed' (the dispatcher
// reserves auto-reject for 404/409); the point is that the core is never
// called and the error names the override.
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toContain('account_override')
expect(categorizeMatchedTransaction).not.toHaveBeenCalled()
})
it('threads a valid stored override into the core opts', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: '4020' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
expect(opts.accountOverride).toBe('4020')
})
it('passes undefined when the staged params carry account_override: null (no override)', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: null },
})
await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
expect(opts.accountOverride).toBeUndefined()
})
})
+20
View File
@@ -43,6 +43,7 @@ import {
resolveUnsettledStatus,
} from '@/lib/supplier-invoices/lifecycle'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
@@ -306,6 +307,23 @@ async function commitCategorizeTransaction(
// propagation all live in the shared core (lib/transactions/categorize-core.ts)
// so the bulk-book-inbox executor and the Underlag "Bokför valda" route reuse
// exactly this logic.
// Tamper/drift gate for the explicit business-side account: a PRESENT but
// malformed account_override must fail loudly, never degrade to the
// category default. The approver approved a preview showing the override
// account, so posting anything else would diverge from what was approved.
const rawAccountOverride = params.account_override
if (
rawAccountOverride != null &&
!(typeof rawAccountOverride === 'string' && ACCOUNT_NUMBER_RE.test(rawAccountOverride))
) {
return {
error:
'Ogiltigt account_override i den stagade operationen (förväntade 4 siffror). ' +
'Avvisa operationen och stagea om kategoriseringen.',
status: 400,
}
}
return categorizeMatchedTransaction(supabase, userId, companyId, txId, {
category,
vatTreatment,
@@ -314,6 +332,8 @@ async function commitCategorizeTransaction(
allowDuplicate: params.allow_duplicate === true,
// Dimensions PR7: resolved at staging; coerce is the drift/tamper gate.
dimensions: coerceDimensionsBag(params.dimensions),
// Validated against the chart both at staging and inside the core at commit.
accountOverride: (rawAccountOverride as string | null | undefined) ?? undefined,
})
}
@@ -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(
+28 -1
View File
@@ -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
+21 -6
View File
@@ -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