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
@@ -7,7 +7,7 @@ import {
|
||||
makeTransaction,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { JournalEntryNotBalancedError } from '@/lib/bookkeeping/errors'
|
||||
import { BookkeepingDatabaseError, JournalEntryNotBalancedError } from '@/lib/bookkeeping/errors'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
@@ -86,6 +86,13 @@ vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
|
||||
reverseOrphanedJournalEntry: (...args: unknown[]) => mockReverseOrphanedJournalEntry(...args),
|
||||
}))
|
||||
|
||||
// Null-return disambiguation (issue #1947): the route asks checkPeriodLock
|
||||
// whether the engine's null was a closed covering period or a missing one.
|
||||
const mockCheckPeriodLock = vi.fn()
|
||||
vi.mock('@/lib/api/v1/check-period-lock', () => ({
|
||||
checkPeriodLock: (...args: unknown[]) => mockCheckPeriodLock(...args),
|
||||
}))
|
||||
|
||||
const mockFindMissingActiveAccounts = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/account-validation', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/account-validation')>(
|
||||
@@ -126,6 +133,8 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
mockDetectDup.mockResolvedValue(null)
|
||||
mockAppendProcessingHistory.mockResolvedValue('evt-1')
|
||||
mockReverseOrphanedJournalEntry.mockResolvedValue(undefined)
|
||||
// Default: no covering period at all. The closed-period test overrides this.
|
||||
mockCheckPeriodLock.mockResolvedValue({ locked: false, reason: 'no_fiscal_period' })
|
||||
})
|
||||
|
||||
it('delegates the CAS-race orphan to engine-backed storno compensation', async () => {
|
||||
@@ -228,35 +237,6 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns a race conflict when the guarded update matches no row without creating an entry', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: null,
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
mockCreateTransactionJournalEntry.mockResolvedValueOnce(null)
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: false },
|
||||
}),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_RACE')
|
||||
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates journal entry for business expense', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
@@ -574,80 +554,133 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments')
|
||||
})
|
||||
|
||||
it('returns success with error when journal entry creation fails (non-blocking)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'Test',
|
||||
journal_entry_id: null,
|
||||
describe('fails closed when the verifikat cannot be created (issue #1947)', () => {
|
||||
// Queue order up to the engine call: tx fetch, settings, settlement
|
||||
// accounts, ensureFiscalPeriod. Nothing after that: a refused verifikat
|
||||
// must not reach the transactions update.
|
||||
const enqueueUpToEngine = () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'Test',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
}
|
||||
|
||||
const categorize = async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
return parseJsonResponse<{
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
message_en?: string
|
||||
details?: { cause?: string; reason?: string; fiscal_period_id?: string }
|
||||
}
|
||||
}>(response)
|
||||
}
|
||||
|
||||
it('refuses the booking and leaves the row untouched when the period is locked', async () => {
|
||||
enqueueUpToEngine()
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(
|
||||
new BookkeepingDatabaseError('commit_entry', 'Cannot write to locked/closed fiscal period "2025"'),
|
||||
)
|
||||
|
||||
const { status, body } = await categorize()
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED')
|
||||
expect(body.error.message).toBe(
|
||||
'Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.',
|
||||
)
|
||||
// The sv/en pair is derived from the SAME underlying error, per the
|
||||
// errorResponseFromCode contract (provide both or neither): the English
|
||||
// side must not stay on the generic registry text while the Swedish
|
||||
// side names the period lock, and must never carry envelope-field prose.
|
||||
expect(body.error.message_en).toBe('Bookkeeping database operation failed.')
|
||||
expect(body.error.message_en).not.toContain('details.cause')
|
||||
expect(body.error.details?.cause).toBe('BOOKKEEPING_DATABASE_ERROR')
|
||||
// Nothing persisted: is_business/category stay NULL so the row keeps
|
||||
// matching the worklist predicate and stays in "Att bokföra".
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
|
||||
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
it('translates typed engine errors to Swedish in the refusal (issue #337)', async () => {
|
||||
enqueueUpToEngine()
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(100, 80))
|
||||
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
const { status, body } = await categorize()
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: [{ ...tx, is_business: true, category: 'expense_software' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_created: boolean
|
||||
journal_entry_error: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(false)
|
||||
// Untyped errors no longer leak their raw English message (issue #337):
|
||||
// they map to the Swedish transaction-context fallback.
|
||||
expect(body.journal_entry_error).toBe('Kunde inte hantera transaktionen. Försök igen.')
|
||||
})
|
||||
|
||||
it('translates typed engine errors to Swedish in journal_entry_error (issue #337)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'Test',
|
||||
journal_entry_id: null,
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED')
|
||||
expect(body.error.message).toContain('balanserar inte')
|
||||
expect(body.error.message).toMatch(/100/)
|
||||
expect(body.error.message).toMatch(/80/)
|
||||
expect(body.error.message).not.toContain('not balanced')
|
||||
expect(body.error.message).not.toContain('check constraint')
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: [], error: null }) // resolveSettlementAccount: no enabled cash accounts -> 1930
|
||||
enqueue({ data: [{ id: 'period-1' }], error: null })
|
||||
it('maps untyped errors to the Swedish transaction fallback without leaking the raw text', async () => {
|
||||
enqueueUpToEngine()
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(new Error('boom'))
|
||||
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(100, 80))
|
||||
const { status, body } = await categorize()
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: [{ ...tx, is_business: true, category: 'expense_software' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED')
|
||||
expect(body.error.message).toBe('Kunde inte hantera transaktionen. Försök igen.')
|
||||
expect(body.error.message).not.toContain('boom')
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_created: boolean
|
||||
journal_entry_error: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(false)
|
||||
expect(body.journal_entry_error).toContain('balanserar inte')
|
||||
expect(body.journal_entry_error).toMatch(/100/)
|
||||
expect(body.journal_entry_error).toMatch(/80/)
|
||||
expect(body.journal_entry_error).not.toContain('not balanced')
|
||||
expect(body.journal_entry_error).not.toContain('check constraint')
|
||||
it('returns NO_OPEN_PERIOD_FOR_DATE when the engine finds no covering period (null entry)', async () => {
|
||||
enqueueUpToEngine()
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue(null)
|
||||
mockCheckPeriodLock.mockResolvedValue({ locked: false, reason: 'no_fiscal_period' })
|
||||
|
||||
const { status, body } = await categorize()
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('NO_OPEN_PERIOD_FOR_DATE')
|
||||
expect(body.error.details?.reason).toBe('no_fiscal_period')
|
||||
// Refused before the CAS write: no update, no orphan, so no storno.
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
|
||||
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns PERIOD_LOCKED (reason period_is_closed) when the covering year is closed', async () => {
|
||||
// findFiscalPeriod filters is_closed = false, so a klarmarkerad year
|
||||
// also surfaces as the engine's null return. The route must not claim
|
||||
// the räkenskapsår does not exist when it exists and is closed.
|
||||
enqueueUpToEngine()
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue(null)
|
||||
mockCheckPeriodLock.mockResolvedValue({
|
||||
locked: true,
|
||||
reason: 'period_is_closed',
|
||||
fiscal_period_id: 'fp-2024',
|
||||
})
|
||||
|
||||
const { status, body } = await categorize()
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('PERIOD_LOCKED')
|
||||
expect(body.error.details?.reason).toBe('period_is_closed')
|
||||
expect(body.error.details?.fiscal_period_id).toBe('fp-2024')
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
|
||||
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 500 when transaction update fails', async () => {
|
||||
|
||||
@@ -7,13 +7,14 @@ import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEnt
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { errorResponse, errorResponseFromCode, getStructuredError } from '@/lib/errors/get-structured-error'
|
||||
import {
|
||||
DUPLICATE_AMOUNT_TOLERANCE_PCT,
|
||||
DUPLICATE_DATE_WINDOW_DAYS,
|
||||
@@ -782,7 +783,6 @@ export const POST = withRouteContext(
|
||||
|
||||
let journalEntryCreated = false
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
let documentLinkWarning: string | null = null
|
||||
|
||||
try {
|
||||
@@ -809,13 +809,53 @@ export const POST = withRouteContext(
|
||||
if (err instanceof AccountsNotInChartError) {
|
||||
return accountsNotInChartResponse(err)
|
||||
}
|
||||
// All errors map to Swedish via getErrorMessage: the raw message is
|
||||
// already logged above and must never reach the user verbatim (issue
|
||||
// #337). The categorization is preserved either way so the user can
|
||||
// still re-book the verifikation manually.
|
||||
journalEntryError = getErrorMessage(err, { context: 'transaction' })
|
||||
// Fail closed (issue #1947): the verifikat IS the booking. Writing
|
||||
// is_business/category without one used to drop the row out of the
|
||||
// canonical worklist predicate in lib/worklist/types.ts (is_business IS
|
||||
// NULL) while it was still unbooked, so it vanished from "Att bokföra"
|
||||
// and the nav badge with no reminder to finish it. Nothing is persisted
|
||||
// when the entry fails: the row stays uncategorized and visible. Both
|
||||
// message locales come from getErrorMessage (sv/en from the same error,
|
||||
// per the errorResponseFromCode contract: provide both or neither); the
|
||||
// raw message is already logged above and must never reach the user
|
||||
// verbatim (issue #337).
|
||||
const structured = getStructuredError(err)
|
||||
return errorResponseFromCode('TX_CATEGORIZE_JOURNAL_ENTRY_FAILED', txLog, {
|
||||
requestId,
|
||||
messageSv: getErrorMessage(err, { context: 'transaction' }),
|
||||
messageEn: getErrorMessage(err, { context: 'transaction', locale: 'en' }),
|
||||
details: { cause: structured.code },
|
||||
})
|
||||
}
|
||||
|
||||
// createTransactionJournalEntry returns null (no throw) when
|
||||
// findFiscalPeriod sees no OPEN period covering the date and the pre-FY
|
||||
// clamp does not apply: either no fiscal period exists there at all, or
|
||||
// the covering period exists but is closed (is_closed = true; findFiscalPeriod
|
||||
// filters is_closed = false). Same fail-closed rule either way: refuse
|
||||
// rather than mark the row categorized-but-unbooked. checkPeriodLock tells
|
||||
// the two apart so a closed year answers PERIOD_LOCKED (reason
|
||||
// period_is_closed) instead of claiming the räkenskapsår does not exist.
|
||||
if (!journalEntryId) {
|
||||
const periodLock = await checkPeriodLock(supabase, companyId, transaction.date)
|
||||
if (periodLock.locked) {
|
||||
return errorResponseFromCode('PERIOD_LOCKED', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
transaction_date: transaction.date,
|
||||
reason: periodLock.reason,
|
||||
fiscal_period_id: periodLock.fiscal_period_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
return errorResponseFromCode('NO_OPEN_PERIOD_FOR_DATE', txLog, {
|
||||
requestId,
|
||||
details: { transaction_date: transaction.date, reason: 'no_fiscal_period' },
|
||||
})
|
||||
}
|
||||
|
||||
// Learning writes (mapping rule, counterparty template) 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 for the merchant.
|
||||
if (is_business && transaction.merchant_name && !mappingResult.direction_mismatch) {
|
||||
@@ -1027,21 +1067,13 @@ export const POST = withRouteContext(
|
||||
},
|
||||
})
|
||||
|
||||
if (journalEntryError) {
|
||||
// Categorization stuck but the verifikation didn't make it through.
|
||||
// Surface as a structured warning: the response below carries the
|
||||
// user-facing message in `journal_entry_error`.
|
||||
txLog.warn('partial outcome: journal entry creation failed', {
|
||||
reason: 'journal_entry_creation_failed',
|
||||
message: journalEntryError,
|
||||
})
|
||||
}
|
||||
|
||||
// journal_entry_error is always null here: a failed verifikat now returns
|
||||
// a typed 409 above (issue #1947). The field stays for client compatibility.
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
journal_entry_created: journalEntryCreated,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_error: journalEntryError,
|
||||
journal_entry_error: null,
|
||||
document_link_warning: documentLinkWarning,
|
||||
category: finalCategory,
|
||||
})
|
||||
|
||||
+23
-9
@@ -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 },
|
||||
|
||||
+94
-17
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user