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