Bug/mcp bas lag (#543)

* fix(mcp): update workflow descriptions for transaction categorization and approval processes

* feat: implement account validation for transaction categorization to handle inactive accounts

* fix(tests): stub findMissingAccountsMock to ensure no missing accounts during batch-categorize tests

* fix(errors): ensure deterministic sorting of account numbers in AccountsNotInChartError
This commit is contained in:
Mattsson
2026-05-20 12:11:27 +02:00
committed by GitHub
parent 00a7886f63
commit 239261a0be
13 changed files with 841 additions and 24 deletions
@@ -47,6 +47,17 @@ vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
}))
const mockFindMissingActiveAccounts = vi.fn()
vi.mock('@/lib/bookkeeping/account-validation', async () => {
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/account-validation')>(
'@/lib/bookkeeping/account-validation',
)
return {
...actual,
findMissingActiveAccounts: (...args: unknown[]) => mockFindMissingActiveAccounts(...args),
}
})
import { POST } from '../route'
describe('POST /api/transactions/[id]/categorize', () => {
@@ -69,6 +80,9 @@ describe('POST /api/transactions/[id]/categorize', () => {
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockBuildMappingResultFromCategory.mockReturnValue(defaultMappingResult)
// Default: every mapped account exists and is active. Tests covering the
// missing-account path override this per-case.
mockFindMissingActiveAccounts.mockResolvedValue([])
})
it('returns 401 when not authenticated', async () => {
@@ -524,4 +538,114 @@ describe('POST /api/transactions/[id]/categorize', () => {
// Should NOT save mapping rule for private transactions
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
})
it('returns 400 ACCOUNTS_NOT_IN_CHART when the mapped debit account is not active in the chart', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: 'GitHub',
journal_entry_id: null,
})
// Fetch transaction
enqueue({ data: tx, error: null })
// Fetch company settings
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
// Mapping built from category — but the debit account is missing/inactive
// in this company's kontoplan. findMissingActiveAccounts is mocked at the
// module level; flag the debit account here to simulate the same outcome
// the engine would otherwise hit at AccountsNotInChartError.
mockFindMissingActiveAccounts.mockResolvedValueOnce(['6200'])
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: true, category: 'expense_software' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; account_numbers: string[]; message: string }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.account_numbers).toEqual(['6200'])
expect(body.error.message).toMatch(/Följande konton behöver aktiveras/)
// Engine must NOT be called once validation flagged a missing account.
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
// No save of mapping rule either — the categorization didn't go through.
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
})
it('returns 400 ACCOUNTS_NOT_IN_CHART listing every missing/inactive account', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -1000,
merchant_name: 'Acme',
journal_entry_id: null,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
// Multiple accounts missing — covers the common "imported a template with
// accounts that this kontoplan never enabled" case.
mockFindMissingActiveAccounts.mockResolvedValueOnce(['5410', '2641'])
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: true, category: 'expense_office' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; account_numbers: string[]; message: string }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
// AccountsNotInChartError sorts + dedupes its input.
expect(body.error.account_numbers).toEqual(['2641', '5410'])
expect(body.error.message).toContain('2641')
expect(body.error.message).toContain('5410')
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 ACCOUNTS_NOT_IN_CHART when the engine throws AccountsNotInChartError (defense in depth)', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: 'GitHub',
journal_entry_id: null,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
// ensureFiscalPeriod existing-period check
enqueue({ data: [{ id: 'period-1' }], error: null })
// Pre-validation says everything is fine — simulates a race where an
// account got deactivated between our chart_of_accounts read and the
// engine's resolveAccountIds read. The engine throws and the route must
// surface a structured 400 rather than the partial-success path that
// would have marked the row bokförd with no verifikation.
const { AccountsNotInChartError } = await import('@/lib/bookkeeping/errors')
mockCreateTransactionJournalEntry.mockRejectedValue(
new AccountsNotInChartError(['6200']),
)
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: true, category: 'expense_software' },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; account_numbers: string[] }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.account_numbers).toEqual(['6200'])
// Transaction update must NOT have run — if it had, the test would have
// had to enqueue a response for it. The absence of an enqueue here plus
// the 400 status is the assertion that the route did not fall through.
})
})
+29 -1
View File
@@ -15,7 +15,8 @@ import {
escapeLikePattern,
normalizeOcrReference,
} from '@/lib/invoices/duplicate-payment-guard'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { AccountsNotInChartError, accountsNotInChartResponse, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { Logger } from '@/lib/logger'
import type { CategorizationTemplate } from '@/types'
@@ -238,6 +239,7 @@ export const POST = withRouteContext(
.select('account_number, account_class')
.eq('company_id', companyId)
.eq('account_number', body.account_override)
.eq('is_active', true)
.single()
if (!accountExists) {
@@ -268,6 +270,23 @@ export const POST = withRouteContext(
})
}
// Pre-validate every account the engine will resolve. Templates,
// counterparty templates, and category defaults can all reference accounts
// that aren't activated in this company's kontoplan. Without this check,
// the engine throws AccountsNotInChartError mid-flight and the legacy
// catch below silently marks the transaction as bokförd with no
// verifikation. Catching it here means the row stays in "Att bokföra"
// and the user gets a clear actionable message.
const missingAccounts = await findMissingActiveAccounts(
supabase,
companyId,
collectMappingResultAccounts(mappingResult),
)
if (missingAccounts.length > 0) {
txLog.warn('mapping references inactive/missing accounts', { missingAccounts })
return accountsNotInChartResponse(new AccountsNotInChartError(missingAccounts))
}
if (body.confirm_no_match && /^244\d$/.test(mappingResult.debit_account)) {
txLog.warn('supplier-invoice match suggestion bypassed', {
reason: 'confirm_no_match=true',
@@ -511,6 +530,15 @@ export const POST = withRouteContext(
}
} catch (err) {
txLog.error('failed to create transaction journal entry', err as Error)
// AccountsNotInChartError means an account was deactivated between our
// pre-validation and the engine call (rare race). Don't fall through to
// the partial-success path — that would mark the transaction bokförd
// with no verifikation and leave the user staring at an unclosable
// dialog. Return a structured 400 so the row stays in "Att bokföra"
// and the user can re-activate the account and retry.
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
// Bookkeeping errors map to Swedish via the registry. Other errors get
// their raw message — the categorization is preserved either way so the
// user can still re-book the verifikation manually.