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:
co-authored by
Claude Fable 5
parent
d182cf5d93
commit
88f53350de
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user