fix(transactions): categorize fails closed when the verifikat cannot be created (#1990)

* fix(transactions): categorize fails closed when the verifikat cannot be created (#1947)

Booking into a locked period refused the verifikat but still wrote
is_business/category, so the row left "Att bokföra" and the nav badge
while journal_entry_id stayed NULL (canonical worklist predicate:
is_business IS NULL). The verifikat is the booking: when it cannot be
created nothing is written and the request returns a typed 409
TX_CATEGORIZE_JOURNAL_ENTRY_FAILED (Swedish reason preserved,
details.cause = underlying code); a null engine return maps to 400
NO_OPEN_PERIOD_FOR_DATE. Same shape on the dashboard route, the v1
single route and per item in v1 batch-categorize. journal_entry_error
stays in the 200 body, always null, for client compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(transactions): fail closed on the engine's null return in the MCP/bulk door too

Review findings on #1990: categorizeMatchedTransaction (pending-op
approval, Underlag bulk-book) still wrote is_business/category with
journal_entry_id NULL when createTransactionJournalEntry returned null
(closed year or missing period return null without throwing), recreating
the exact #1947 stranding while the tool reported success. The core now
refuses before the transactions update with a structured 400 whose
errorCode (PERIOD_LOCKED or NO_OPEN_PERIOD_FOR_DATE, told apart via
checkPeriodLock) flows into result_data.error_code; the bulk driver
skips such items with reason no_open_period.

The dashboard route's null guard gets the same disambiguation: a closed
covering year answers PERIOD_LOCKED (reason period_is_closed) instead
of claiming the rakenskapsar does not exist, and the thrown-error branch
now pairs messageSv with messageEn per the errorResponseFromCode
contract. TX_CATEGORIZE_JOURNAL_ENTRY_FAILED message_en no longer
embeds API-doc prose (details.cause guidance lives in remediation).
DECISIONS line corrected: the MCP door was fail-closed only for thrown
engine errors, not the null return.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-27 22:25:11 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent cb9ae15d46
commit f0af4ad4ee
11 changed files with 587 additions and 163 deletions
@@ -7,7 +7,7 @@ import {
makeTransaction,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import { JournalEntryNotBalancedError } from '@/lib/bookkeeping/errors'
import { BookkeepingDatabaseError, JournalEntryNotBalancedError } from '@/lib/bookkeeping/errors'
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
@@ -86,6 +86,13 @@ vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
reverseOrphanedJournalEntry: (...args: unknown[]) => mockReverseOrphanedJournalEntry(...args),
}))
// Null-return disambiguation (issue #1947): the route asks checkPeriodLock
// whether the engine's null was a closed covering period or a missing one.
const mockCheckPeriodLock = vi.fn()
vi.mock('@/lib/api/v1/check-period-lock', () => ({
checkPeriodLock: (...args: unknown[]) => mockCheckPeriodLock(...args),
}))
const mockFindMissingActiveAccounts = vi.fn()
vi.mock('@/lib/bookkeeping/account-validation', async () => {
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/account-validation')>(
@@ -126,6 +133,8 @@ describe('POST /api/transactions/[id]/categorize', () => {
mockDetectDup.mockResolvedValue(null)
mockAppendProcessingHistory.mockResolvedValue('evt-1')
mockReverseOrphanedJournalEntry.mockResolvedValue(undefined)
// Default: no covering period at all. The closed-period test overrides this.
mockCheckPeriodLock.mockResolvedValue({ locked: false, reason: 'no_fiscal_period' })
})
it('delegates the CAS-race orphan to engine-backed storno compensation', async () => {
@@ -228,35 +237,6 @@ describe('POST /api/transactions/[id]/categorize', () => {
).toHaveLength(2)
})
it('returns a race conflict when the guarded update matches no row without creating an entry', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: null,
journal_entry_id: null,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'period-1' }], error: null })
mockCreateTransactionJournalEntry.mockResolvedValueOnce(null)
enqueue({ data: [], error: null })
const response = await POST(
createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: false },
}),
createMockRouteParams({ id: 'tx-1' }),
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_RACE')
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
})
it('creates journal entry for business expense', async () => {
const tx = makeTransaction({
id: 'tx-1',
@@ -574,80 +554,133 @@ describe('POST /api/transactions/[id]/categorize', () => {
expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments')
})
it('returns success with error when journal entry creation fails (non-blocking)', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: 'Test',
journal_entry_id: null,
describe('fails closed when the verifikat cannot be created (issue #1947)', () => {
// Queue order up to the engine call: tx fetch, settings, settlement
// accounts, ensureFiscalPeriod. Nothing after that: a refused verifikat
// must not reach the transactions update.
const enqueueUpToEngine = () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: 'Test',
journal_entry_id: null,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'period-1' }], error: null })
}
const categorize = async () => {
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' }))
return parseJsonResponse<{
error: {
code: string
message: string
message_en?: string
details?: { cause?: string; reason?: string; fiscal_period_id?: string }
}
}>(response)
}
it('refuses the booking and leaves the row untouched when the period is locked', async () => {
enqueueUpToEngine()
mockCreateTransactionJournalEntry.mockRejectedValue(
new BookkeepingDatabaseError('commit_entry', 'Cannot write to locked/closed fiscal period "2025"'),
)
const { status, body } = await categorize()
expect(status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED')
expect(body.error.message).toBe(
'Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.',
)
// The sv/en pair is derived from the SAME underlying error, per the
// errorResponseFromCode contract (provide both or neither): the English
// side must not stay on the generic registry text while the Swedish
// side names the period lock, and must never carry envelope-field prose.
expect(body.error.message_en).toBe('Bookkeeping database operation failed.')
expect(body.error.message_en).not.toContain('details.cause')
expect(body.error.details?.cause).toBe('BOOKKEEPING_DATABASE_ERROR')
// Nothing persisted: is_business/category stay NULL so the row keeps
// matching the worklist predicate and stays in "Att bokföra".
expect(findCalls('transactions', 'update')).toEqual([])
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'period-1' }], error: null })
it('translates typed engine errors to Swedish in the refusal (issue #337)', async () => {
enqueueUpToEngine()
mockCreateTransactionJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(100, 80))
mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked'))
const { status, body } = await categorize()
// Update transaction
enqueue({ data: [{ ...tx, is_business: true, category: 'expense_software' }], error: null })
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<{
success: boolean
journal_entry_created: boolean
journal_entry_error: string
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_created).toBe(false)
// Untyped errors no longer leak their raw English message (issue #337):
// they map to the Swedish transaction-context fallback.
expect(body.journal_entry_error).toBe('Kunde inte hantera transaktionen. Försök igen.')
})
it('translates typed engine errors to Swedish in journal_entry_error (issue #337)', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
merchant_name: 'Test',
journal_entry_id: null,
expect(status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED')
expect(body.error.message).toContain('balanserar inte')
expect(body.error.message).toMatch(/100/)
expect(body.error.message).toMatch(/80/)
expect(body.error.message).not.toContain('not balanced')
expect(body.error.message).not.toContain('check constraint')
expect(findCalls('transactions', 'update')).toEqual([])
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'period-1' }], error: null })
it('maps untyped errors to the Swedish transaction fallback without leaking the raw text', async () => {
enqueueUpToEngine()
mockCreateTransactionJournalEntry.mockRejectedValue(new Error('boom'))
mockCreateTransactionJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(100, 80))
const { status, body } = await categorize()
// Update transaction
enqueue({ data: [{ ...tx, is_business: true, category: 'expense_software' }], error: null })
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: true, category: 'expense_software' },
expect(status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED')
expect(body.error.message).toBe('Kunde inte hantera transaktionen. Försök igen.')
expect(body.error.message).not.toContain('boom')
expect(findCalls('transactions', 'update')).toEqual([])
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_created: boolean
journal_entry_error: string
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_created).toBe(false)
expect(body.journal_entry_error).toContain('balanserar inte')
expect(body.journal_entry_error).toMatch(/100/)
expect(body.journal_entry_error).toMatch(/80/)
expect(body.journal_entry_error).not.toContain('not balanced')
expect(body.journal_entry_error).not.toContain('check constraint')
it('returns NO_OPEN_PERIOD_FOR_DATE when the engine finds no covering period (null entry)', async () => {
enqueueUpToEngine()
mockCreateTransactionJournalEntry.mockResolvedValue(null)
mockCheckPeriodLock.mockResolvedValue({ locked: false, reason: 'no_fiscal_period' })
const { status, body } = await categorize()
expect(status).toBe(400)
expect(body.error.code).toBe('NO_OPEN_PERIOD_FOR_DATE')
expect(body.error.details?.reason).toBe('no_fiscal_period')
// Refused before the CAS write: no update, no orphan, so no storno.
expect(findCalls('transactions', 'update')).toEqual([])
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
})
it('returns PERIOD_LOCKED (reason period_is_closed) when the covering year is closed', async () => {
// findFiscalPeriod filters is_closed = false, so a klarmarkerad year
// also surfaces as the engine's null return. The route must not claim
// the räkenskapsår does not exist when it exists and is closed.
enqueueUpToEngine()
mockCreateTransactionJournalEntry.mockResolvedValue(null)
mockCheckPeriodLock.mockResolvedValue({
locked: true,
reason: 'period_is_closed',
fiscal_period_id: 'fp-2024',
})
const { status, body } = await categorize()
expect(status).toBe(400)
expect(body.error.code).toBe('PERIOD_LOCKED')
expect(body.error.details?.reason).toBe('period_is_closed')
expect(body.error.details?.fiscal_period_id).toBe('fp-2024')
expect(findCalls('transactions', 'update')).toEqual([])
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
})
})
it('returns 500 when transaction update fails', async () => {
+50 -18
View File
@@ -7,13 +7,14 @@ import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEnt
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { errorResponse, errorResponseFromCode, getStructuredError } from '@/lib/errors/get-structured-error'
import {
DUPLICATE_AMOUNT_TOLERANCE_PCT,
DUPLICATE_DATE_WINDOW_DAYS,
@@ -782,7 +783,6 @@ export const POST = withRouteContext(
let journalEntryCreated = false
let journalEntryId: string | null = null
let journalEntryError: string | null = null
let documentLinkWarning: string | null = null
try {
@@ -809,13 +809,53 @@ export const POST = withRouteContext(
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
// All errors map to Swedish via getErrorMessage: the raw message is
// already logged above and must never reach the user verbatim (issue
// #337). The categorization is preserved either way so the user can
// still re-book the verifikation manually.
journalEntryError = getErrorMessage(err, { context: 'transaction' })
// Fail closed (issue #1947): the verifikat IS the booking. Writing
// is_business/category without one used to drop the row out of the
// canonical worklist predicate in lib/worklist/types.ts (is_business IS
// NULL) while it was still unbooked, so it vanished from "Att bokföra"
// and the nav badge with no reminder to finish it. Nothing is persisted
// when the entry fails: the row stays uncategorized and visible. Both
// message locales come from getErrorMessage (sv/en from the same error,
// per the errorResponseFromCode contract: provide both or neither); the
// raw message is already logged above and must never reach the user
// verbatim (issue #337).
const structured = getStructuredError(err)
return errorResponseFromCode('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED', txLog, {
requestId,
messageSv: getErrorMessage(err, { context: 'transaction' }),
messageEn: getErrorMessage(err, { context: 'transaction', locale: 'en' }),
details: { cause: structured.code },
})
}
// createTransactionJournalEntry returns null (no throw) when
// findFiscalPeriod sees no OPEN period covering the date and the pre-FY
// clamp does not apply: either no fiscal period exists there at all, or
// the covering period exists but is closed (is_closed = true; findFiscalPeriod
// filters is_closed = false). Same fail-closed rule either way: refuse
// rather than mark the row categorized-but-unbooked. checkPeriodLock tells
// the two apart so a closed year answers PERIOD_LOCKED (reason
// period_is_closed) instead of claiming the räkenskapsår does not exist.
if (!journalEntryId) {
const periodLock = await checkPeriodLock(supabase, companyId, transaction.date)
if (periodLock.locked) {
return errorResponseFromCode('PERIOD_LOCKED', txLog, {
requestId,
details: {
transaction_date: transaction.date,
reason: periodLock.reason,
fiscal_period_id: periodLock.fiscal_period_id,
},
})
}
return errorResponseFromCode('NO_OPEN_PERIOD_FOR_DATE', txLog, {
requestId,
details: { transaction_date: transaction.date, reason: 'no_fiscal_period' },
})
}
// Learning writes (mapping rule, counterparty template) run only after a
// posted verifikat, so they never learn from a booking that did not happen.
// direction_mismatch = a mirrored refund/repayment booking; learning it
// as a rule would store backwards accounts for the merchant.
if (is_business && transaction.merchant_name && !mappingResult.direction_mismatch) {
@@ -1027,21 +1067,13 @@ export const POST = withRouteContext(
},
})
if (journalEntryError) {
// Categorization stuck but the verifikation didn't make it through.
// Surface as a structured warning: the response below carries the
// user-facing message in `journal_entry_error`.
txLog.warn('partial outcome: journal entry creation failed', {
reason: 'journal_entry_creation_failed',
message: journalEntryError,
})
}
// journal_entry_error is always null here: a failed verifikat now returns
// a typed 409 above (issue #1947). The field stays for client compatibility.
return NextResponse.json({
success: true,
journal_entry_created: journalEntryCreated,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
journal_entry_error: null,
document_link_warning: documentLinkWarning,
category: finalCategory,
})
@@ -69,7 +69,7 @@ vi.mock('@/lib/transactions/inbox-underlag', () => ({
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { withUnusedVoucherAllocation } from '@/lib/bookkeeping/errors'
import { BookkeepingDatabaseError, withUnusedVoucherAllocation } from '@/lib/bookkeeping/errors'
import { POST } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
@@ -220,10 +220,12 @@ describe('POST /api/v1/.../transactions/{id}/categorize underlag propagation', (
)
})
it('does not propagate on the partial-success path (no journal entry was created)', async () => {
const { supabase } = happyPathSupabase()
it('refuses the booking and writes nothing when the journal entry cannot be created (issue #1947)', async () => {
const { supabase, updates } = happyPathSupabase()
mockServiceClient.mockReturnValue(supabase)
createTxJE.mockRejectedValueOnce(new Error('transient engine failure'))
createTxJE.mockRejectedValueOnce(
new BookkeepingDatabaseError('commit_entry', 'Cannot write to locked/closed fiscal period "2026"'),
)
const res = await POST(
makeRequest({ is_business: true, category: 'expense_office' }),
@@ -231,8 +233,17 @@ describe('POST /api/v1/.../transactions/{id}/categorize underlag propagation', (
)
const body = await res.json()
expect(body.data.journal_entry_created).toBe(false)
expect(res.status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED')
expect(body.error.details.cause).toBe('BOOKKEEPING_DATABASE_ERROR')
expect(body.error.details.message).toBe(
'Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.',
)
// The row is untouched: is_business/category stay NULL so it remains in
// the unbooked queue instead of vanishing as categorized-but-unbooked.
expect(updates.transactions).toBeUndefined()
expect(propagateUnderlagMock).not.toHaveBeenCalled()
expect(reverseEntryMock).not.toHaveBeenCalled()
})
it('does not propagate when the CAS race is lost (the verifikat was stornoed)', async () => {
@@ -249,8 +260,8 @@ describe('POST /api/v1/.../transactions/{id}/categorize underlag propagation', (
expect(propagateUnderlagMock).not.toHaveBeenCalled()
})
it('returns a race conflict when the guarded update matches no row without creating an entry', async () => {
const { supabase } = casRaceSupabase()
it('returns NO_OPEN_PERIOD_FOR_DATE and writes nothing when the engine finds no covering period', async () => {
const { supabase, updates } = happyPathSupabase()
mockServiceClient.mockReturnValue(supabase)
createTxJE.mockResolvedValueOnce(null)
@@ -260,9 +271,12 @@ describe('POST /api/v1/.../transactions/{id}/categorize underlag propagation', (
)
const body = await res.json()
expect(res.status).toBe(409)
expect(body.error.code).toBe('TX_CATEGORIZE_RACE')
expect(res.status).toBe(400)
expect(body.error.code).toBe('NO_OPEN_PERIOD_FOR_DATE')
// Refused before the CAS write: no update, no orphan, so no storno.
expect(updates.transactions).toBeUndefined()
expect(reverseEntryMock).not.toHaveBeenCalled()
expect(propagateUnderlagMock).not.toHaveBeenCalled()
})
it('atomically unignores an ignored transaction when categorizing it', async () => {
@@ -10,6 +10,12 @@
* entry, only the is_business / category flags are updated. The JE is left
* intact (it's immutable post-commit per BFL 5 kap 6 §).
*
* Fail-closed (issue #1947): if the verifikat cannot be created (locked
* period, unbalanced entry, engine error) the request is refused with 409
* TX_CATEGORIZE_JOURNAL_ENTRY_FAILED and the transaction is left untouched,
* so it stays in the unbooked queue. journal_entry_error in the 200 body is
* therefore always null and kept only for response-shape compatibility.
*
* Dry-runnable: returns the resolved mapping (debit/credit + VAT lines)
* without inserting the journal entry or mutating the transaction.
*/
@@ -35,10 +41,11 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { getStructuredError } from '@/lib/errors/get-structured-error'
import { eventBus } from '@/lib/events'
import type {
CategorizationTemplate,
@@ -51,7 +58,12 @@ const CategorizeResponse = z.object({
success: z.boolean(),
journal_entry_created: z.boolean(),
journal_entry_id: z.string().uuid().nullable(),
journal_entry_error: z.string().nullable(),
journal_entry_error: z
.string()
.nullable()
.describe(
'Always null: a verifikat that cannot be created is refused with 409 TX_CATEGORIZE_JOURNAL_ENTRY_FAILED and nothing is written (issue #1947). Kept for response-shape compatibility.',
),
document_link_warning: z.string().nullable().optional(),
category: z.string(),
already_had_journal_entry: z.boolean().optional(),
@@ -377,7 +389,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// suggestions; we preserve that behavior so v1 and the dashboard
// diverge on neither booking outcomes nor compliance.
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
ctx.supabase,
@@ -397,17 +408,36 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (err instanceof AccountsNotInChartError) {
return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
}
if (isBookkeepingError(err)) {
journalEntryError = getErrorMessage(err, { context: 'transaction' })
} else {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// Fail closed (issue #1947): the verifikat IS the booking. Persisting
// is_business/category without one dropped the row out of the worklist
// predicate (is_business IS NULL) while still unbooked. Nothing is
// written; the Swedish reason travels in details.message because the
// v1 envelope keeps the registry text as the top-level message.
return v1ErrorResponseFromCode('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED', txLog, {
requestId: ctx.requestId,
details: {
cause: getStructuredError(err).code,
message: getErrorMessage(err, { context: 'transaction' }),
},
})
}
// createTransactionJournalEntry returns null (no throw) when no fiscal
// period covers the date and the pre-FY clamp does not apply. Same
// fail-closed rule: refuse rather than mark the row categorized-but-unbooked.
if (!journalEntryId) {
return v1ErrorResponseFromCode('NO_OPEN_PERIOD_FOR_DATE', txLog, {
requestId: ctx.requestId,
details: { transaction_date: transaction.date },
})
}
// Best-effort: save mapping rule + upsert counterparty template. These
// are user-experience polish (faster future categorization) and never
// fail the request. direction_mismatch = a mirrored refund/repayment
// booking; learning it as a rule would store backwards accounts.
// fail the request. They run only after a posted verifikat, so they never
// learn from a booking that did not happen. direction_mismatch = a
// mirrored refund/repayment booking; learning it as a rule would store
// backwards accounts.
if (is_business && transaction.merchant_name && !mappingResult.direction_mismatch) {
try {
await saveUserMappingRule(
@@ -511,12 +541,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
txLog.warn('transaction.categorized emit failed (non-critical)', err as Error)
}
// journal_entry_error is always null here: a failed verifikat returns a
// typed 409 TX_CATEGORIZE_JOURNAL_ENTRY_FAILED above (issue #1947). The
// field stays for response-shape compatibility.
return ok(
{
success: true,
journal_entry_created: !!journalEntryId,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
journal_entry_error: null,
category: finalCategory,
},
{ requestId: ctx.requestId },
@@ -237,24 +237,21 @@ describe('POST batch-categorize', () => {
)
})
it('returns a per-item race conflict when the guarded update matches no row without creating an entry', async () => {
const { supabase } = makeFlexibleSupabase({
it('returns a per-item NO_OPEN_PERIOD_FOR_DATE and writes nothing when the engine finds no covering period', async () => {
const { supabase, updates } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: [
{
data: {
id: TX_A,
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
journal_entry_id: null,
},
error: null,
transactions: {
data: {
id: TX_A,
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
journal_entry_id: null,
},
{ data: [], error: 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 },
})
@@ -272,8 +269,12 @@ describe('POST batch-categorize', () => {
)
const body = await res.json()
expect(body.data.results[0].error.code).toBe('TX_CATEGORIZE_RACE')
expect(body.data.results[0].ok).toBe(false)
expect(body.data.results[0].error.code).toBe('NO_OPEN_PERIOD_FOR_DATE')
expect(body.data.results[0].error.details.transaction_date).toBe('2026-05-12')
expect(body.data.summary).toEqual({ total: 1, succeeded: 0, failed: 1 })
// Refused before the CAS write (issue #1947): no update, no orphan, no storno.
expect(updates.transactions).toBeUndefined()
expect(reverseEntryMock).not.toHaveBeenCalled()
})
@@ -592,6 +593,82 @@ describe('POST batch-categorize', () => {
expect(body.data.summary).toEqual({ total: 1, succeeded: 0, failed: 1 })
})
it('refuses the item and writes nothing when the journal entry cannot be created, while a clean sibling still books (issue #1947)', async () => {
const { supabase, updates } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: [
// 1: item A fetch. 2: item B fetch. 3: item B CAS update (item A
// never reaches its update: the refusal returns first).
{
data: {
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
journal_entry_id: null,
},
error: null,
},
{
data: {
company_id: COMPANY_ID,
date: '2026-05-13',
amount: -120,
currency: 'SEK',
merchant_name: 'Coop',
journal_entry_id: null,
},
error: null,
},
{ data: [{ id: TX_B }], 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 },
})
mockServiceClient.mockReturnValue(supabase)
const { BookkeepingDatabaseError } = await import('@/lib/bookkeeping/errors')
createTxJE.mockRejectedValueOnce(
new BookkeepingDatabaseError('commit_entry', 'Cannot write to locked/closed fiscal period "2026"'),
)
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].transaction_id).toBe(TX_A)
expect(body.data.results[0].error.code).toBe('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED')
expect(body.data.results[0].error.message).toBe(
'Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.',
)
expect(body.data.results[0].error.details.cause).toBe('BOOKKEEPING_DATABASE_ERROR')
expect(body.data.results[1].ok).toBe(true)
expect(body.data.results[1].data.journal_entry_id).toBe('je-fresh')
expect(body.data.summary).toEqual({ total: 2, succeeded: 1, failed: 1 })
// Only the clean sibling reached the transactions update: the refused
// item stays uncategorized so it remains in the unbooked queue.
expect(updates.transactions).toHaveLength(1)
expect(updates.transactions[0]).toEqual(
expect.objectContaining({ is_business: true, journal_entry_id: 'je-fresh' }),
)
expect(propagateUnderlagMock).toHaveBeenCalledTimes(1)
expect(propagateUnderlagMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, TX_B, 'je-fresh')
})
it('documents the stranded voucher with the real voucher_gap_explanations columns when the CAS-race storno fails', async () => {
const { supabase, inserts } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -340,7 +340,6 @@ async function categorizeOne(
}
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
const je = await createTransactionJournalEntry(
supabase,
@@ -372,10 +371,37 @@ async function categorizeOne(
},
}
}
if (isBookkeepingError(err)) {
journalEntryError = getErrorMessage(err, { context: 'transaction' })
} else {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
// Fail closed (issue #1947): the verifikat IS the booking. Any other
// engine failure is a per-item refusal with nothing written, so the row
// keeps matching the worklist predicate (is_business IS NULL) instead of
// vanishing from "Att bokföra" as categorized-but-unbooked.
return {
ok: false,
request_index: index,
transaction_id: transactionId,
error: {
code: 'TX_CATEGORIZE_JOURNAL_ENTRY_FAILED',
message: getErrorMessage(err, { context: 'transaction' }),
details: { cause: getStructuredError(err).code },
},
}
}
// createTransactionJournalEntry returns null (no throw) when no fiscal
// period covers the date and the pre-FY clamp does not apply. Same
// fail-closed rule: refuse the item rather than mark it categorized-but-unbooked.
if (!journalEntryId) {
return {
ok: false,
request_index: index,
transaction_id: transactionId,
error: {
code: 'NO_OPEN_PERIOD_FOR_DATE',
message:
getErrorEntry('NO_OPEN_PERIOD_FOR_DATE')?.message_sv ??
'Det finns ingen räkenskapsperiod som täcker det valda datumet.',
details: { transaction_date: transaction.date },
},
}
}
@@ -459,7 +485,9 @@ async function categorizeOne(
data: {
journal_entry_created: !!journalEntryId,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
// Always null: a failed verifikat is a per-item refusal above (#1947).
// Kept for response-shape compatibility.
journal_entry_error: null,
category: finalCategory,
},
}