diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx
index 00a0db4e..a790dce4 100644
--- a/app/(dashboard)/transactions/page.tsx
+++ b/app/(dashboard)/transactions/page.tsx
@@ -519,6 +519,66 @@ export default function TransactionsPage() {
setProcessingId(null)
return null
}
+ if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') {
+ // The mapped template/category references one or more accounts
+ // that aren't active in this company's kontoplan. Without an
+ // inline action the user has to navigate to settings, activate
+ // each account, and come back — surface a one-click "Aktivera
+ // och bokför" instead.
+ const accountNumbers: string[] =
+ (Array.isArray(result.error.account_numbers) && result.error.account_numbers) ||
+ (Array.isArray(result.error.details?.account_numbers) && result.error.details.account_numbers) ||
+ []
+ // Synchronous in-flight flag per toast closure: a double-click
+ // would otherwise fire two activate+categorize pairs, where the
+ // second categorize races the first's verifikation insert.
+ let activateInFlight = false
+ toast({
+ title: 'Kontot finns inte i din kontoplan',
+ description: `Bokföringsmallen kräver att följande konton aktiveras: ${accountNumbers.join(', ')}.`,
+ variant: 'destructive',
+ action: accountNumbers.length > 0 ? (
+ {
+ if (activateInFlight) return
+ activateInFlight = true
+ try {
+ const activateRes = await fetch('/api/bookkeeping/accounts/activate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account_numbers: accountNumbers }),
+ })
+ if (!activateRes.ok) {
+ const errBody = await activateRes.json().catch(() => null)
+ toast({
+ title: 'Kunde inte aktivera konton',
+ description: getErrorMessage(errBody, { statusCode: activateRes.status }),
+ variant: 'destructive',
+ })
+ return
+ }
+ const activateBody = await activateRes.json()
+ // unknown[] = numbers not in BAS reference at all. Those
+ // can't be auto-created; tell the user to add them manually.
+ if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) {
+ toast({
+ title: 'Kunde inte hitta alla konton',
+ description: `Lägg till ${activateBody.unknown.join(', ')} manuellt under Inställningar → Kontoplan.`,
+ variant: 'destructive',
+ })
+ return
+ }
+ await runCategorize(args)
+ } finally {
+ activateInFlight = false
+ }
+ }}>
+ Aktivera och bokför
+
+ ) : undefined,
+ })
+ setProcessingId(null)
+ return null
+ }
toast({
title: 'Kategorisering misslyckades',
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
@@ -1250,28 +1310,99 @@ export default function TransactionsPage() {
let journalEntryId: string | null
if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) {
const cpTemplateId = extractCounterpartyId(quickReview.template.id)
- const response = await fetch(`/api/transactions/${id}/categorize`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- is_business: true,
- counterparty_template_id: cpTemplateId,
- }),
- })
- const result = await response.json()
- if (!response.ok) {
- toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive' })
+ const cpCategorize = async (): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[] } }; journal_entry_id?: string | null }; status: number }> => {
+ const r = await fetch(`/api/transactions/${id}/categorize`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ is_business: true, counterparty_template_id: cpTemplateId }),
+ })
+ const b = await r.json()
+ return { ok: r.ok, status: r.status, result: b, journalEntryId: b?.journal_entry_id || null }
+ }
+ const { ok: cpOk, status: cpStatus, result, journalEntryId: cpJeId } = await cpCategorize()
+ if (!cpOk) {
+ if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') {
+ const accountNumbers: string[] =
+ (Array.isArray(result.error.account_numbers) && result.error.account_numbers) ||
+ (Array.isArray(result.error.details?.account_numbers) && result.error.details?.account_numbers) ||
+ []
+ // Synchronous in-flight flag per toast closure — see same pattern
+ // in runCategorize. Double-click on the counterparty-template
+ // retry would race the second cpCategorize against the first's
+ // verifikation insert.
+ let activateInFlight = false
+ toast({
+ title: 'Kontot finns inte i din kontoplan',
+ description: `Motpartsmallen kräver att följande konton aktiveras: ${accountNumbers.join(', ')}.`,
+ variant: 'destructive',
+ action: accountNumbers.length > 0 ? (
+ {
+ if (activateInFlight) return
+ activateInFlight = true
+ try {
+ const activateRes = await fetch('/api/bookkeeping/accounts/activate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account_numbers: accountNumbers }),
+ })
+ if (!activateRes.ok) {
+ const errBody = await activateRes.json().catch(() => null)
+ toast({ title: 'Kunde inte aktivera konton', description: getErrorMessage(errBody, { statusCode: activateRes.status }), variant: 'destructive' })
+ return
+ }
+ const activateBody = await activateRes.json()
+ if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) {
+ toast({ title: 'Kunde inte hitta alla konton', description: `Lägg till ${activateBody.unknown.join(', ')} manuellt under Inställningar → Kontoplan.`, variant: 'destructive' })
+ return
+ }
+ const retry = await cpCategorize()
+ // Gate on retry.ok alone: a 200 with null journal_entry_id
+ // is allowed by the declared type (e.g. already-categorized
+ // flag flip), and showing "Kategorisering misslyckades"
+ // after the server returned success is misleading. The state
+ // update conditionally writes the journal_entry_id when it's
+ // actually present.
+ if (retry.ok) {
+ setExitingIds((prev) => new Set(prev).add(id))
+ setTransactions((prev) =>
+ prev.map((t) =>
+ t.id === id
+ ? { ...t, is_business: true, ...(retry.journalEntryId ? { journal_entry_id: retry.journalEntryId } : {}) }
+ : t
+ )
+ )
+ toast({ title: 'Bokförd' })
+ } else {
+ toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(retry.result, { context: 'transaction', statusCode: retry.status }), variant: 'destructive' })
+ }
+ } finally {
+ activateInFlight = false
+ }
+ }}>
+ Aktivera och bokför
+
+ ) : undefined,
+ })
+ } else {
+ toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction', statusCode: cpStatus }), variant: 'destructive' })
+ }
+ // Close the review dialog on hard errors — the toast (with action if
+ // ACCOUNTS_NOT_IN_CHART) carries the message and the recovery path.
+ setQuickReviewOpen(false)
+ setQuickReview(null)
return null
}
setExitingIds((prev) => new Set(prev).add(id))
- journalEntryId = result.journal_entry_id || null
+ journalEntryId = cpJeId
} else {
journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
}
- if (journalEntryId) {
- setQuickReviewOpen(false)
- setQuickReview(null)
- }
+ // Always close — whether the server created a verifikation, returned a
+ // structured 4xx (ACCOUNTS_NOT_IN_CHART, INVALID_MAPPING, …), or hit a
+ // partial-success path. The toast from runCategorize already communicates
+ // the outcome; keeping the dialog open serves no purpose.
+ setQuickReviewOpen(false)
+ setQuickReview(null)
return journalEntryId
}
diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts
index 5d00e66e..01d29d8f 100644
--- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts
@@ -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(
+ '@/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.
+ })
})
diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts
index 6f8fb20b..956c8a01 100644
--- a/app/api/transactions/[id]/categorize/route.ts
+++ b/app/api/transactions/[id]/categorize/route.ts
@@ -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.
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts
index c577264b..b0bbf5ae 100644
--- a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts
@@ -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(
+ '@/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', () => {
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts
index 52fdd610..a7a9a08f 100644
--- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts
@@ -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 {
diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts
new file mode 100644
index 00000000..575e35b9
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts
@@ -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('@/lib/auth/api-keys')
+ return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
+})
+vi.mock('@supabase/supabase-js', async () => {
+ const actual = await vi.importActual('@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(
+ '@/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
+const mockServiceClient = createServiceClientNoCookies as ReturnType
+
+type MockResult = { data?: unknown; error?: unknown }
+function makeFlexibleSupabase(byTable: Record) {
+ const queues = new Map()
+ for (const [t, val] of Object.entries(byTable)) {
+ queues.set(t, Array.isArray(val) ? [...val] : [val])
+ }
+ const buildChain = (table: string): unknown => {
+ const handler: ProxyHandler