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
+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
}