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
+11
View File
@@ -465,6 +465,17 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
message_sv: 'Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.',
message_en: 'Transaction was already categorized by another request.',
},
TX_CATEGORIZE_JOURNAL_ENTRY_FAILED: {
httpStatus: 409,
message_sv:
'Verifikationen kunde inte skapas, så transaktionen är inte bokförd. Den ligger kvar under Att bokföra.',
message_en:
'The journal entry could not be created, so the transaction was not booked and stays in the unbooked list.',
remediation: {
description:
'Fix the cause in details.cause (PERIOD_LOCKED / BOOKKEEPING_DATABASE_ERROR with a locked-period message: unlock the period or use gnubok_unlock_period; NO_OPEN_PERIOD_FOR_DATE: create or open the fiscal year) and retry the same request. Nothing was written.',
},
},
TX_CATEGORIZE_IGNORED_CONFLICT: {
httpStatus: 409,
message_sv:
@@ -0,0 +1,148 @@
/**
* categorizeMatchedTransaction fails closed on the engine's null return
* (issue #1947).
*
* createTransactionJournalEntry returns null WITHOUT throwing when
* findFiscalPeriod sees no OPEN period covering the date and the pre-FY clamp
* does not apply: the covering rakenskapsar is closed (is_closed = true), or
* no period exists there at all. The pre-fix core fell through to the
* transactions update anyway, writing is_business/category with
* journal_entry_id NULL: the row left "Att bokfora" and the nav badge while
* still unbooked, and the pending operation / bulk driver reported success.
*
* These tests pin the guard: on a null entry NOTHING is written (no
* transactions update, no counterparty template, no event) and the caller
* gets a structured 400 whose errorCode distinguishes a closed period
* (PERIOD_LOCKED) from a missing one (NO_OPEN_PERIOD_FOR_DATE), so
* result_data.error_code carries it through the pending-operations layer.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const mockCreateJE = vi.fn()
const mockReverseOrphanedJE = vi.fn()
const mockUpsertTemplate = vi.fn()
const mockCheckPeriodLock = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
}))
vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
reverseOrphanedJournalEntry: (...args: unknown[]) => mockReverseOrphanedJE(...args),
}))
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: (...args: unknown[]) => mockUpsertTemplate(...args),
}))
vi.mock('@/lib/transactions/link-journal-entry', () => ({
hasLiveJournalEntryLink: vi.fn().mockResolvedValue(false),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/api/v1/check-period-lock', () => ({
checkPeriodLock: (...args: unknown[]) => mockCheckPeriodLock(...args),
}))
import { categorizeMatchedTransaction } from '../categorize-core'
const TX_ID = '00000000-0000-4000-8000-0000000000aa'
/** A transaction dated inside a fiscal year that is klarmarkerad (closed). */
const txRow = (over: Record<string, unknown> = {}) => ({
id: TX_ID,
company_id: 'company-1',
date: '2024-11-15',
amount: -1200,
currency: 'SEK',
amount_sek: -1200,
exchange_rate: 1,
description: 'PROGRAMVARA AB',
merchant_name: null,
cash_account_id: null,
document_id: null,
journal_entry_id: null,
...over,
})
const settingsRow = { entity_type: 'aktiebolag', fiscal_year_start_month: 1 }
/**
* Queue the reads up to the engine call: transactions select,
* company_settings, resolveSettlementAccount, ensureFiscalPeriod (no open
* period, earliest period start not after the date, upsert bounces off the
* closed year's range: return value is ignored by the caller).
*/
function enqueueUpToEngine(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: txRow() }) // transactions select
enqueue({ data: settingsRow }) // company_settings
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [] }) // ensureFiscalPeriod: no OPEN period covers the date
enqueue({ data: [{ period_start: '2024-01-01' }] }) // earliest period start (pre-FY guard passes)
enqueue({ data: null }) // ensureFiscalPeriod upsert; its outcome is ignored by the caller
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockCreateJE.mockResolvedValue(null) // the engine's no-open-period null return
})
describe('categorizeMatchedTransaction: null engine return fails closed (issue #1947)', () => {
it('refuses with PERIOD_LOCKED and writes nothing when the covering year is closed', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
const categorizedHandler = vi.fn()
eventBus.on('transaction.categorized', categorizedHandler)
enqueueUpToEngine(enqueue)
mockCheckPeriodLock.mockResolvedValue({
locked: true,
reason: 'period_is_closed',
fiscal_period_id: 'fp-2024',
})
const result = await categorizeMatchedTransaction(
supabase as never,
'user-1',
'company-1',
TX_ID,
{ category: 'expense_software' },
)
expect(result.status).toBe(400)
expect(result.errorCode).toBe('PERIOD_LOCKED')
expect(result.error).toBe('Bokföringen är låst för denna period.')
expect(result.data).toBeUndefined()
// The #1947 defect: the update ran anyway and stranded the row as
// categorized-but-unbooked. Nothing may be written now.
expect(findCalls('transactions', 'update')).toEqual([])
expect(mockUpsertTemplate).not.toHaveBeenCalled()
expect(mockReverseOrphanedJE).not.toHaveBeenCalled()
expect(categorizedHandler).not.toHaveBeenCalled()
expect(mockCheckPeriodLock).toHaveBeenCalledWith(expect.anything(), 'company-1', '2024-11-15')
})
it('refuses with NO_OPEN_PERIOD_FOR_DATE and writes nothing when no period exists', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueUpToEngine(enqueue)
mockCheckPeriodLock.mockResolvedValue({ locked: false, reason: 'no_fiscal_period' })
const result = await categorizeMatchedTransaction(
supabase as never,
'user-1',
'company-1',
TX_ID,
{ category: 'expense_software' },
)
expect(result.status).toBe(400)
expect(result.errorCode).toBe('NO_OPEN_PERIOD_FOR_DATE')
expect(result.error).toContain('räkenskapsperiod')
expect(findCalls('transactions', 'update')).toEqual([])
expect(mockUpsertTemplate).not.toHaveBeenCalled()
})
})
@@ -106,14 +106,17 @@ describe('categorizeMatchedTransaction: accountOverride', () => {
)
})
it('returns a race conflict when the guarded update matches no row without creating an entry', async () => {
it('returns a race conflict and stornos the orphan when the guarded update matches no row', async () => {
// Pre-#1947 this scenario reached the guarded update via a null engine
// return; a null entry now fails closed BEFORE the update (see
// categorize-core.fail-closed.test.ts), so the race is exercised with a
// posted entry, whose orphan must be reversed.
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: settingsRow })
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'fp-1' }] })
enqueue({ data: [] })
mockCreateJE.mockResolvedValueOnce(null)
enqueue({ data: [] }) // guarded update: no row matched (concurrent categorization)
const result = await categorizeMatchedTransaction(
supabase as never,
@@ -124,7 +127,13 @@ describe('categorizeMatchedTransaction: accountOverride', () => {
)
expect(result.status).toBe(409)
expect(mockReverseOrphanedJE).not.toHaveBeenCalled()
expect(mockReverseOrphanedJE).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-override-1',
expect.any(String),
)
})
it('posts the entry with the override on the business side', async () => {
+42 -4
View File
@@ -46,6 +46,8 @@ import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { createLogger } from '@/lib/logger'
import { getStructuredError } from '@/lib/errors/get-structured-error'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import type { InboxChannelContext, Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
const log = createLogger('transactions/categorize-core')
@@ -54,6 +56,12 @@ const log = createLogger('transactions/categorize-core')
export interface CategorizeCoreResult {
data?: Record<string, unknown>
error?: string
/**
* Structured-error registry code for `error`, when the core has one.
* commit.ts surfaces it as CommitResult.code and persists it in
* result_data.error_code so approvers can branch on the failure mode.
*/
errorCode?: string
status?: number
}
@@ -410,6 +418,34 @@ export async function categorizeMatchedTransaction(
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
}
// createTransactionJournalEntry returns null WITHOUT throwing when
// findFiscalPeriod sees no OPEN period covering the date and the pre-FY
// clamp does not apply: either no rakenskapsar exists there at all, or the
// covering period is closed (is_closed = true; a locked_at-only lock throws
// from the DB trigger and is rethrown above as a bookkeeping error). Fail
// closed exactly like the HTTP routes (issue #1947): refuse BEFORE the
// transactions update below, so the row never leaves "Att bokfora" as
// categorized-but-unbooked (journal_entry_id NULL) and the pending
// operation or bulk driver reports the failure instead of success.
// checkPeriodLock tells the two null causes apart for an honest message.
if (!journalEntryId) {
const verdict = await checkPeriodLock(supabase, companyId, transaction.date)
const code = verdict.locked ? 'PERIOD_LOCKED' : 'NO_OPEN_PERIOD_FOR_DATE'
log.warn('journal entry refused: no open fiscal period for date', {
txId,
companyId,
date: transaction.date,
reason: verdict.reason ?? null,
})
return {
error:
getErrorEntry(code)?.message_sv ??
'Det finns ingen öppen räkenskapsperiod som täcker transaktionsdatumet.',
errorCode: code,
status: 400,
}
}
const updateQuery = supabase
.from('transactions')
.update({
@@ -614,10 +650,12 @@ export async function bulkBookMatchedInboxItems(
if (result.error) {
const reason =
result.status === 404 ? 'transaction_not_found'
: result.status === 409 ? 'already_booked_or_duplicate'
: result.status === 400 ? 'no_account_mapping'
: 'error'
result.errorCode === 'PERIOD_LOCKED' || result.errorCode === 'NO_OPEN_PERIOD_FOR_DATE'
? 'no_open_period'
: result.status === 404 ? 'transaction_not_found'
: result.status === 409 ? 'already_booked_or_duplicate'
: result.status === 400 ? 'no_account_mapping'
: 'error'
skipped.push({ item_id: itemId, reason, detail: result.error })
continue
}