fix(errors): translate typed engine errors instead of leaking raw messages as journal_entry_error (#1048)

Typed bookkeeping Error instances passed to getErrorMessage() matched the
bare-envelope branch (any object with string code + message) and returned
their raw English message verbatim, so the categorize and match-invoice
routes surfaced strings like DB check-constraint violations directly in the
user's toast (issue #337).

- get-error-message.ts: when the bare-envelope shape is an Error instance,
  normalize it into the structured envelope ({ error: { code, message,
  account_numbers, details } }) so the existing per-code Swedish branches
  own the translation; plain forwarded envelopes keep the passthrough.
- get-error-message.ts: structured-path final fallback now prefers the
  registry's message_sv for known codes whose message is not Swedish, so
  typed codes without a dynamic branch (e.g. CANNOT_REVERSE_STORNO) cannot
  surface English either.
- categorize + match-invoice routes: always map the caught error through
  getErrorMessage (the raw error is already logged); untyped errors fall to
  the Swedish context fallback instead of leaking err.message.
- Tests: new instance-translation suite in lib/errors, typed-error case in
  the categorize route suite, and deliberate updates of the two tests that
  pinned raw 'Period locked' passthrough.

Fixes #337

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-17 11:51:07 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d182cf5d93
commit 88f53350de
6 changed files with 141 additions and 18 deletions
@@ -7,6 +7,7 @@ import {
makeTransaction,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import { JournalEntryNotBalancedError } from '@/lib/bookkeeping/errors'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
@@ -325,7 +326,47 @@ describe('POST /api/transactions/[id]/categorize', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_created).toBe(false)
expect(body.journal_entry_error).toBe('Period locked')
// 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,
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: [{ id: 'period-1' }], error: null })
mockCreateTransactionJournalEntry.mockRejectedValue(new JournalEntryNotBalancedError(100, 80))
// Update transaction
enqueue({ data: null, 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)
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 500 when transaction update fails', async () => {
@@ -18,7 +18,7 @@ import {
escapeLikePattern,
normalizeOcrReference,
} from '@/lib/invoices/duplicate-payment-guard'
import { AccountsNotInChartError, accountsNotInChartResponse, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { Logger } from '@/lib/logger'
@@ -640,14 +640,11 @@ export const POST = withRouteContext(
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
// Bookkeeping errors map to Swedish via the registry. Other errors get
// their raw message: the categorization is preserved either way so the
// user can still re-book the verifikation manually.
if (isBookkeepingError(err)) {
journalEntryError = getErrorMessage(err, { context: 'transaction' })
} else {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// 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' })
}
// direction_mismatch = a mirrored refund/repayment booking; learning it
@@ -1048,7 +1048,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBeNull()
expect(body.journal_entry_error).toBe('Period locked')
// Untyped errors no longer leak their raw English message (issue #337):
// they map to the Swedish invoice-context fallback.
expect(body.journal_entry_error).toBe('Kunde inte hantera fakturan. Försök igen.')
})
// ────────────────────────────────────────────────────────────────
@@ -4,7 +4,7 @@ import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-paym
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -480,12 +480,9 @@ export const POST = withRouteContext(
}
txLog.error('failed to create payment journal entry', err as Error)
// Other errors are recorded but don't abort the match: the user can
// re-book the verifikation manually.
if (isBookkeepingError(err)) {
journalEntryError = getErrorMessage(err, { context: 'invoice' })
} else {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// re-book the verifikation manually. All errors map to Swedish via
// getErrorMessage; the raw message must never reach the user (issue #337).
journalEntryError = getErrorMessage(err, { context: 'invoice' })
}
// Underlag for the payment verifikation: re-attach the invoice PDF that