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,
})