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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
cb9ae15d46
commit
f0af4ad4ee
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user