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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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