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.
@@ -28,12 +28,16 @@ vi.mock('@supabase/supabase-js', async () => {
})
// Engine stubs — happy-path returns reusable across cases.
const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE } = vi.hoisted(() => ({
const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE, findMissingAccountsMock } = vi.hoisted(() => ({
createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }),
reverseEntryMock: vi.fn().mockResolvedValue(undefined),
createInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-invpmt' }),
createInvCashJE: vi.fn().mockResolvedValue({ id: 'je-invcash' }),
createSupplierInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-sipmt' }),
// Default: no missing accounts. Per-case overrides simulate the
// template-references-inactive-account bug or a race where deactivation
// happened between our validation and the engine's resolveAccountIds.
findMissingAccountsMock: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
@@ -60,6 +64,15 @@ vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
buildMappingResultFromCounterpartyTemplate: 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: findMissingAccountsMock,
}
})
// category mapping is real — provides the debit/credit account guarantees.
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
@@ -218,6 +231,90 @@ describe('POST :id/categorize', () => {
const body = await res.json()
expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
})
it('returns 400 ACCOUNTS_NOT_IN_CHART when mapped accounts are not active in the kontoplan', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_ID,
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
}),
)
// Simulate the user-reported bug: a category/template that maps to an
// account they haven't activated in their kontoplan.
findMissingAccountsMock.mockResolvedValueOnce(['5410'])
const res = await categorizePOST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`,
{ is_business: true, category: 'expense_office' },
),
txParams(TX_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
// The v1 envelope routes typed bookkeeping errors through
// extractBookkeepingDetails, which places account_numbers under details.
expect(body.error.details.account_numbers).toEqual(['5410'])
// Engine and transaction-update must NOT run — the row stays in the
// categorization queue so the user can re-activate and retry.
expect(createTxJE).not.toHaveBeenCalled()
})
it('returns 400 ACCOUNTS_NOT_IN_CHART when the engine throws mid-flight (defense in depth)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_ID,
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
}),
)
// Pre-validation passes — race condition where an account got
// deactivated between our chart_of_accounts read and the engine's
// resolveAccountIds read. The engine throws and the catch in the route
// must short-circuit to a structured 400 rather than falling through
// to the partial-success branch that would mark the row bokförd with
// no verifikation.
findMissingAccountsMock.mockResolvedValueOnce([])
const { AccountsNotInChartError } = await import('@/lib/bookkeeping/errors')
createTxJE.mockRejectedValueOnce(new AccountsNotInChartError(['5410']))
const res = await categorizePOST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`,
{ is_business: true, category: 'expense_office' },
),
txParams(TX_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.details.account_numbers).toEqual(['5410'])
})
})
describe('POST :id/uncategorize', () => {
@@ -34,7 +34,8 @@ import {
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events'
import type {
@@ -250,6 +251,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
.select('account_number, account_class')
.eq('company_id', ctx.companyId!)
.eq('account_number', body.account_override)
.eq('is_active', true)
.single()
if (!accountExists) {
return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_ACCOUNT', txLog, {
@@ -285,6 +287,25 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Pre-validate every account in the mapping against the company's
// chart_of_accounts. Template / counterparty-template / category paths
// all bypass the older account_override check; without this catch they
// would reach the engine and throw AccountsNotInChartError mid-flight,
// leaving the legacy partial-success branch to silently mark the row as
// bokförd with no verifikation. We validate in both live AND dry-run
// paths so previews surface the same actionable error.
const missingAccounts = await findMissingActiveAccounts(
ctx.supabase,
ctx.companyId!,
collectMappingResultAccounts(mappingResult),
)
if (missingAccounts.length > 0) {
txLog.warn('mapping references inactive/missing accounts', { missingAccounts })
return v1ErrorResponse(new AccountsNotInChartError(missingAccounts), txLog, {
requestId: ctx.requestId,
})
}
// Dry-run stops here — caller sees the resolved mapping without burning
// a voucher number or mutating any state.
if (ctx.dryRun) {
@@ -341,6 +362,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (journalEntry) journalEntryId = journalEntry.id
} catch (err) {
txLog.error('transactions.categorize: journal entry creation failed', err as Error)
// AccountsNotInChartError means an account was deactivated between our
// pre-validation and the engine call (race). Don't fall through to the
// partial-success path that would mark the row bokförd with no
// verifikation — return a structured 400 so the row stays in the
// categorization queue and the caller can retry after re-activating.
if (err instanceof AccountsNotInChartError) {
return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
}
if (isBookkeepingError(err)) {
journalEntryError = getErrorMessage(err, { context: 'transaction' })
} else {
@@ -0,0 +1,214 @@
/**
* Integration tests for POST /api/v1/companies/{companyId}/transactions/batch-categorize.
*
* Covers the missing-account guard: when a categorization references an
* account that isn't active in the company's kontoplan, the per-item result
* must surface as ACCOUNTS_NOT_IN_CHART without ever marking the row bokförd.
* Other items in the same batch continue independently (partial-success
* semantics).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const { createTxJE, findMissingAccountsMock } = vi.hoisted(() => ({
createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }),
// Default: every mapped account exists and is active. Per-test overrides
// simulate the bug surface.
findMissingAccountsMock: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: createTxJE,
}))
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: vi.fn().mockResolvedValue(undefined),
}))
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: findMissingAccountsMock,
}
})
// category mapping is real — gives the route real BAS accounts to validate.
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const TX_A = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const TX_B = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
function makeRequest(url: string, body: unknown): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab',
},
body: JSON.stringify(body),
})
}
function batchParams() {
return { params: Promise.resolve({ companyId: COMPANY_ID }) }
}
beforeEach(() => {
vi.clearAllMocks()
findMissingAccountsMock.mockResolvedValue([])
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
scopes: ['transactions:write'],
mode: 'live',
})
})
describe('POST batch-categorize', () => {
it('returns per-item ACCOUNTS_NOT_IN_CHART for items whose mapping references inactive accounts; clean items still succeed', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
// Each `transactions` lookup returns the same shape; the flexible
// proxy serves both items from this single result. amount is < 0 so
// both map to an expense flow.
transactions: {
data: {
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
}),
)
// First item: mapping references an inactive account. Second item: clean.
findMissingAccountsMock
.mockResolvedValueOnce(['5410'])
.mockResolvedValueOnce([])
const res = await POST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
{
items: [
{ transaction_id: TX_A, categorization: { is_business: true, category: 'expense_office' } },
{ transaction_id: TX_B, categorization: { is_business: true, category: 'expense_office' } },
],
},
),
batchParams(),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.results).toHaveLength(2)
expect(body.data.results[0].ok).toBe(false)
expect(body.data.results[0].request_index).toBe(0)
expect(body.data.results[0].error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.data.results[0].error.details.account_numbers).toEqual(['5410'])
expect(body.data.results[1].ok).toBe(true)
expect(body.data.results[1].request_index).toBe(1)
expect(body.data.summary).toEqual({ total: 2, succeeded: 1, failed: 1 })
// Engine must only be called for the clean item.
expect(createTxJE).toHaveBeenCalledTimes(1)
})
it('returns ACCOUNTS_NOT_IN_CHART when the engine throws AccountsNotInChartError mid-flight (defense in depth)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
}),
)
// Pre-validation passes — race where an account got deactivated between
// our chart_of_accounts read and the engine's resolveAccountIds read.
findMissingAccountsMock.mockResolvedValueOnce([])
const { AccountsNotInChartError } = await import('@/lib/bookkeeping/errors')
createTxJE.mockRejectedValueOnce(new AccountsNotInChartError(['5410']))
const res = await POST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
{
items: [
{ transaction_id: TX_A, categorization: { is_business: true, category: 'expense_office' } },
],
},
),
batchParams(),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.results).toHaveLength(1)
expect(body.data.results[0].ok).toBe(false)
expect(body.data.results[0].error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.data.results[0].error.details.account_numbers).toEqual(['5410'])
expect(body.data.summary).toEqual({ total: 1, succeeded: 0, failed: 1 })
})
})
@@ -26,7 +26,8 @@ import {
} from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
@@ -197,6 +198,31 @@ async function categorizeOne(
}
}
// Pre-validate every account in the mapping against the company's
// chart_of_accounts. Templates and category defaults can reference accounts
// that aren't activated in this company's kontoplan; without this check the
// engine throws AccountsNotInChartError mid-flight and the legacy
// partial-success branch silently marks the row bokförd with no
// verifikation. Validate in both dry-run and live paths so previews
// surface the same actionable error.
const missingAccounts = await findMissingActiveAccounts(
supabase,
companyId,
collectMappingResultAccounts(mappingResult),
)
if (missingAccounts.length > 0) {
return {
ok: false,
request_index: index,
transaction_id: transactionId,
error: {
code: 'ACCOUNTS_NOT_IN_CHART',
message: `Följande konton behöver aktiveras: ${missingAccounts.join(', ')}`,
details: { account_numbers: missingAccounts },
},
}
}
if (dryRun) {
return {
ok: true,
@@ -279,6 +305,23 @@ async function categorizeOne(
request_index: index,
transactionId,
})
// AccountsNotInChartError means an account was deactivated between our
// pre-validation and the engine call (rare race). Return the per-item
// failure WITHOUT the transaction update below so the row stays in
// "Att bokföra" — partial-success on a missing-account error would
// mark it bokförd with no verifikation.
if (err instanceof AccountsNotInChartError) {
return {
ok: false,
request_index: index,
transaction_id: transactionId,
error: {
code: 'ACCOUNTS_NOT_IN_CHART',
message: `Följande konton behöver aktiveras: ${err.accountNumbers.join(', ')}`,
details: { account_numbers: err.accountNumbers },
},
}
}
if (isBookkeepingError(err)) {
journalEntryError = getErrorMessage(err, { context: 'transaction' })
} else {
@@ -19,7 +19,7 @@ vi.mock('@supabase/supabase-js', async () => {
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const { ingestMock, createTxJE } = vi.hoisted(() => ({
const { ingestMock, createTxJE, findMissingAccountsMock } = vi.hoisted(() => ({
ingestMock: vi.fn().mockResolvedValue({
imported: 2,
duplicates: 1,
@@ -30,6 +30,12 @@ const { ingestMock, createTxJE } = vi.hoisted(() => ({
transaction_ids: ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'],
}),
createTxJE: vi.fn().mockResolvedValue({ id: 'je-bc' }),
// batch-categorize pre-validates every mapped account against chart_of_accounts
// (commit 6afb13aa). The flexible-Supabase proxy returns { data: null } for
// unmocked tables, which would make the real implementation report ALL
// accounts as missing and short-circuit every item with ACCOUNTS_NOT_IN_CHART.
// Stub it to "no missing accounts" so the happy path is exercised.
findMissingAccountsMock: vi.fn().mockResolvedValue([]),
}))
vi.mock('@/lib/transactions/ingest', () => ({
@@ -38,6 +44,15 @@ vi.mock('@/lib/transactions/ingest', () => ({
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: createTxJE,
}))
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: findMissingAccountsMock,
}
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as ingestPOST } from '../route'