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
@@ -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 () => {
|
||||
|
||||
@@ -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