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:
@@ -1298,6 +1298,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response_<ts>_pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record<string, unknown>[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build.
|
||||
[2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change.
|
||||
[2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up.
|
||||
[2026-08-27] Issue #1947: categorize fails closed (typed 409 TX_CATEGORIZE_JOURNAL_ENTRY_FAILED, nothing written) instead of adding a fourth worklist state for categorised-but-unbooked rows: the verifikat is the booking, and a new state would touch the load-bearing is_business IS NULL predicate, lockPeriod guard and badges. The MCP/bulk door (categorizeMatchedTransaction) was fail-closed only for thrown engine errors, not the engine's null return (closed year or missing period), so the same null guard now refuses there too, before any transactions write, with errorCode PERIOD_LOCKED or NO_OPEN_PERIOD_FOR_DATE via checkPeriodLock. Existing stranded prod rows left for a separate founder-approved repair.
|
||||
[2026-08-27] reverseEntry resets is_business, category and reconciliation_method together with journal_entry_id when it unlinks bank transactions (#1950): the worklist predicate is is_business IS NULL (lib/worklist/types.ts), so clearing only the link hid stornoed rows from Att bokföra and the nav badge while the reverse_warning dialog promised the opposite. The predicate was not switched to journal_entry_id IS NULL because bulk-booked and multi-allocated rows keep it NULL while booked (lib/transactions/is-booked.ts). Fixed in the engine, not per reverse route, so dashboard, v1 and MCP stornos all agree.
|
||||
[2026-08-27] reverseEntry also deletes the reversed entry's transaction_voucher_links rows and releases (is_business, category, reconciliation_method to null) only the rows left with no anchor: bulk-booked samlingsverifikat anchor N>1 bank rows through the junction alone, so the journal_entry_id-scoped reset (#1950) matched nothing there and the rows stayed out of Att bokföra against a reversed entry. The release is guarded by a remaining-links read plus journal_entry_id IS NULL because a residual booking (lib/reconciliation/residual.ts) keeps the main verifikat in journal_entry_id while its junction row points at the residual verifikat; stornoing the residual must not put a still-booked row back in the list.
|
||||
[2026-08-27] fix-cash-mismatch restores the full booked triple (is_business, category, reconciliation_method) in its transaction relink instead of reverseEntry growing a skip-reset option: the remediation re-books the same economic event immediately, so the caller mirrors the match-invoice route's final update; an engine opt-out flag would invite every reverse caller to bypass the #1950 return-to-Att-bokfora guarantee.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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