diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 00a0db4e..a790dce4 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -519,6 +519,66 @@ export default function TransactionsPage() { setProcessingId(null) return null } + if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') { + // The mapped template/category references one or more accounts + // that aren't active in this company's kontoplan. Without an + // inline action the user has to navigate to settings, activate + // each account, and come back — surface a one-click "Aktivera + // och bokför" instead. + const accountNumbers: string[] = + (Array.isArray(result.error.account_numbers) && result.error.account_numbers) || + (Array.isArray(result.error.details?.account_numbers) && result.error.details.account_numbers) || + [] + // Synchronous in-flight flag per toast closure: a double-click + // would otherwise fire two activate+categorize pairs, where the + // second categorize races the first's verifikation insert. + let activateInFlight = false + toast({ + title: 'Kontot finns inte i din kontoplan', + description: `Bokföringsmallen kräver att följande konton aktiveras: ${accountNumbers.join(', ')}.`, + variant: 'destructive', + action: accountNumbers.length > 0 ? ( + { + if (activateInFlight) return + activateInFlight = true + try { + const activateRes = await fetch('/api/bookkeeping/accounts/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account_numbers: accountNumbers }), + }) + if (!activateRes.ok) { + const errBody = await activateRes.json().catch(() => null) + toast({ + title: 'Kunde inte aktivera konton', + description: getErrorMessage(errBody, { statusCode: activateRes.status }), + variant: 'destructive', + }) + return + } + const activateBody = await activateRes.json() + // unknown[] = numbers not in BAS reference at all. Those + // can't be auto-created; tell the user to add them manually. + if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) { + toast({ + title: 'Kunde inte hitta alla konton', + description: `Lägg till ${activateBody.unknown.join(', ')} manuellt under Inställningar → Kontoplan.`, + variant: 'destructive', + }) + return + } + await runCategorize(args) + } finally { + activateInFlight = false + } + }}> + Aktivera och bokför + + ) : undefined, + }) + setProcessingId(null) + return null + } toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }), @@ -1250,28 +1310,99 @@ export default function TransactionsPage() { let journalEntryId: string | null if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) { const cpTemplateId = extractCounterpartyId(quickReview.template.id) - const response = await fetch(`/api/transactions/${id}/categorize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - is_business: true, - counterparty_template_id: cpTemplateId, - }), - }) - const result = await response.json() - if (!response.ok) { - toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive' }) + const cpCategorize = async (): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[] } }; journal_entry_id?: string | null }; status: number }> => { + const r = await fetch(`/api/transactions/${id}/categorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_business: true, counterparty_template_id: cpTemplateId }), + }) + const b = await r.json() + return { ok: r.ok, status: r.status, result: b, journalEntryId: b?.journal_entry_id || null } + } + const { ok: cpOk, status: cpStatus, result, journalEntryId: cpJeId } = await cpCategorize() + if (!cpOk) { + if (result?.error?.code === 'ACCOUNTS_NOT_IN_CHART') { + const accountNumbers: string[] = + (Array.isArray(result.error.account_numbers) && result.error.account_numbers) || + (Array.isArray(result.error.details?.account_numbers) && result.error.details?.account_numbers) || + [] + // Synchronous in-flight flag per toast closure — see same pattern + // in runCategorize. Double-click on the counterparty-template + // retry would race the second cpCategorize against the first's + // verifikation insert. + let activateInFlight = false + toast({ + title: 'Kontot finns inte i din kontoplan', + description: `Motpartsmallen kräver att följande konton aktiveras: ${accountNumbers.join(', ')}.`, + variant: 'destructive', + action: accountNumbers.length > 0 ? ( + { + if (activateInFlight) return + activateInFlight = true + try { + const activateRes = await fetch('/api/bookkeeping/accounts/activate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account_numbers: accountNumbers }), + }) + if (!activateRes.ok) { + const errBody = await activateRes.json().catch(() => null) + toast({ title: 'Kunde inte aktivera konton', description: getErrorMessage(errBody, { statusCode: activateRes.status }), variant: 'destructive' }) + return + } + const activateBody = await activateRes.json() + if (Array.isArray(activateBody.unknown) && activateBody.unknown.length > 0) { + toast({ title: 'Kunde inte hitta alla konton', description: `Lägg till ${activateBody.unknown.join(', ')} manuellt under Inställningar → Kontoplan.`, variant: 'destructive' }) + return + } + const retry = await cpCategorize() + // Gate on retry.ok alone: a 200 with null journal_entry_id + // is allowed by the declared type (e.g. already-categorized + // flag flip), and showing "Kategorisering misslyckades" + // after the server returned success is misleading. The state + // update conditionally writes the journal_entry_id when it's + // actually present. + if (retry.ok) { + setExitingIds((prev) => new Set(prev).add(id)) + setTransactions((prev) => + prev.map((t) => + t.id === id + ? { ...t, is_business: true, ...(retry.journalEntryId ? { journal_entry_id: retry.journalEntryId } : {}) } + : t + ) + ) + toast({ title: 'Bokförd' }) + } else { + toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(retry.result, { context: 'transaction', statusCode: retry.status }), variant: 'destructive' }) + } + } finally { + activateInFlight = false + } + }}> + Aktivera och bokför + + ) : undefined, + }) + } else { + toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction', statusCode: cpStatus }), variant: 'destructive' }) + } + // Close the review dialog on hard errors — the toast (with action if + // ACCOUNTS_NOT_IN_CHART) carries the message and the recovery path. + setQuickReviewOpen(false) + setQuickReview(null) return null } setExitingIds((prev) => new Set(prev).add(id)) - journalEntryId = result.journal_entry_id || null + journalEntryId = cpJeId } else { journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId) } - if (journalEntryId) { - setQuickReviewOpen(false) - setQuickReview(null) - } + // Always close — whether the server created a verifikation, returned a + // structured 4xx (ACCOUNTS_NOT_IN_CHART, INVALID_MAPPING, …), or hit a + // partial-success path. The toast from runCategorize already communicates + // the outcome; keeping the dialog open serves no purpose. + setQuickReviewOpen(false) + setQuickReview(null) return journalEntryId } diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 5d00e66e..01d29d8f 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -47,6 +47,17 @@ vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined), })) +const mockFindMissingActiveAccounts = vi.fn() +vi.mock('@/lib/bookkeeping/account-validation', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/account-validation', + ) + return { + ...actual, + findMissingActiveAccounts: (...args: unknown[]) => mockFindMissingActiveAccounts(...args), + } +}) + import { POST } from '../route' describe('POST /api/transactions/[id]/categorize', () => { @@ -69,6 +80,9 @@ describe('POST /api/transactions/[id]/categorize', () => { eventBus.clear() mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) mockBuildMappingResultFromCategory.mockReturnValue(defaultMappingResult) + // Default: every mapped account exists and is active. Tests covering the + // missing-account path override this per-case. + mockFindMissingActiveAccounts.mockResolvedValue([]) }) it('returns 401 when not authenticated', async () => { @@ -524,4 +538,114 @@ describe('POST /api/transactions/[id]/categorize', () => { // Should NOT save mapping rule for private transactions expect(mockSaveUserMappingRule).not.toHaveBeenCalled() }) + + it('returns 400 ACCOUNTS_NOT_IN_CHART when the mapped debit account is not active in the chart', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + merchant_name: 'GitHub', + journal_entry_id: null, + }) + + // Fetch transaction + enqueue({ data: tx, error: null }) + // Fetch company settings + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + // Mapping built from category — but the debit account is missing/inactive + // in this company's kontoplan. findMissingActiveAccounts is mocked at the + // module level; flag the debit account here to simulate the same outcome + // the engine would otherwise hit at AccountsNotInChartError. + mockFindMissingActiveAccounts.mockResolvedValueOnce(['6200']) + + 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<{ + error: { code: string; account_numbers: string[]; message: string } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART') + expect(body.error.account_numbers).toEqual(['6200']) + expect(body.error.message).toMatch(/Följande konton behöver aktiveras/) + // Engine must NOT be called once validation flagged a missing account. + expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() + // No save of mapping rule either — the categorization didn't go through. + expect(mockSaveUserMappingRule).not.toHaveBeenCalled() + }) + + it('returns 400 ACCOUNTS_NOT_IN_CHART listing every missing/inactive account', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -1000, + merchant_name: 'Acme', + journal_entry_id: null, + }) + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + // Multiple accounts missing — covers the common "imported a template with + // accounts that this kontoplan never enabled" case. + mockFindMissingActiveAccounts.mockResolvedValueOnce(['5410', '2641']) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_office' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; account_numbers: string[]; message: string } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART') + // AccountsNotInChartError sorts + dedupes its input. + expect(body.error.account_numbers).toEqual(['2641', '5410']) + expect(body.error.message).toContain('2641') + expect(body.error.message).toContain('5410') + expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 400 ACCOUNTS_NOT_IN_CHART when the engine throws AccountsNotInChartError (defense in depth)', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + merchant_name: 'GitHub', + journal_entry_id: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + // ensureFiscalPeriod existing-period check + enqueue({ data: [{ id: 'period-1' }], error: null }) + + // Pre-validation says everything is fine — simulates a race where an + // account got deactivated between our chart_of_accounts read and the + // engine's resolveAccountIds read. The engine throws and the route must + // surface a structured 400 rather than the partial-success path that + // would have marked the row bokförd with no verifikation. + const { AccountsNotInChartError } = await import('@/lib/bookkeeping/errors') + mockCreateTransactionJournalEntry.mockRejectedValue( + new AccountsNotInChartError(['6200']), + ) + + 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<{ + error: { code: string; account_numbers: string[] } + }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART') + expect(body.error.account_numbers).toEqual(['6200']) + // Transaction update must NOT have run — if it had, the test would have + // had to enqueue a response for it. The absence of an enqueue here plus + // the 400 status is the assertion that the route did not fall through. + }) }) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 6f8fb20b..956c8a01 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -15,7 +15,8 @@ import { escapeLikePattern, normalizeOcrReference, } from '@/lib/invoices/duplicate-payment-guard' -import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { AccountsNotInChartError, accountsNotInChartResponse, isBookkeepingError } from '@/lib/bookkeeping/errors' +import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { Logger } from '@/lib/logger' import type { CategorizationTemplate } from '@/types' @@ -238,6 +239,7 @@ export const POST = withRouteContext( .select('account_number, account_class') .eq('company_id', companyId) .eq('account_number', body.account_override) + .eq('is_active', true) .single() if (!accountExists) { @@ -268,6 +270,23 @@ export const POST = withRouteContext( }) } + // Pre-validate every account the engine will resolve. Templates, + // counterparty templates, and category defaults can all reference accounts + // that aren't activated in this company's kontoplan. Without this check, + // the engine throws AccountsNotInChartError mid-flight and the legacy + // catch below silently marks the transaction as bokförd with no + // verifikation. Catching it here means the row stays in "Att bokföra" + // and the user gets a clear actionable message. + const missingAccounts = await findMissingActiveAccounts( + supabase, + companyId, + collectMappingResultAccounts(mappingResult), + ) + if (missingAccounts.length > 0) { + txLog.warn('mapping references inactive/missing accounts', { missingAccounts }) + return accountsNotInChartResponse(new AccountsNotInChartError(missingAccounts)) + } + if (body.confirm_no_match && /^244\d$/.test(mappingResult.debit_account)) { txLog.warn('supplier-invoice match suggestion bypassed', { reason: 'confirm_no_match=true', @@ -511,6 +530,15 @@ export const POST = withRouteContext( } } catch (err) { txLog.error('failed to create transaction journal entry', err as Error) + // AccountsNotInChartError means an account was deactivated between our + // pre-validation and the engine call (rare race). Don't fall through to + // the partial-success path — that would mark the transaction bokförd + // with no verifikation and leave the user staring at an unclosable + // dialog. Return a structured 400 so the row stays in "Att bokföra" + // and the user can re-activate the account and retry. + 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. diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts index c577264b..b0bbf5ae 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts @@ -28,12 +28,16 @@ vi.mock('@supabase/supabase-js', async () => { }) // Engine stubs — happy-path returns reusable across cases. -const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE } = vi.hoisted(() => ({ +const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE, findMissingAccountsMock } = vi.hoisted(() => ({ createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }), reverseEntryMock: vi.fn().mockResolvedValue(undefined), createInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-invpmt' }), createInvCashJE: vi.fn().mockResolvedValue({ id: 'je-invcash' }), createSupplierInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-sipmt' }), + // Default: no missing accounts. Per-case overrides simulate the + // template-references-inactive-account bug or a race where deactivation + // happened between our validation and the engine's resolveAccountIds. + findMissingAccountsMock: vi.fn().mockResolvedValue([]), })) vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ @@ -60,6 +64,15 @@ vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined), buildMappingResultFromCounterpartyTemplate: vi.fn(), })) +vi.mock('@/lib/bookkeeping/account-validation', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/account-validation', + ) + return { + ...actual, + findMissingActiveAccounts: findMissingAccountsMock, + } +}) // category mapping is real — provides the debit/credit account guarantees. import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' @@ -218,6 +231,90 @@ describe('POST :id/categorize', () => { const body = await res.json() expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND') }) + + it('returns 400 ACCOUNTS_NOT_IN_CHART when mapped accounts are not active in the kontoplan', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { + data: { + id: TX_ID, + company_id: COMPANY_ID, + date: '2026-05-12', + amount: -349.5, + currency: 'SEK', + merchant_name: 'ICA', + journal_entry_id: null, + }, + error: null, + }, + company_settings: { data: { entity_type: 'enskild_firma' }, error: null }, + }), + ) + // Simulate the user-reported bug: a category/template that maps to an + // account they haven't activated in their kontoplan. + findMissingAccountsMock.mockResolvedValueOnce(['5410']) + + const res = await categorizePOST( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`, + { is_business: true, category: 'expense_office' }, + ), + txParams(TX_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART') + // The v1 envelope routes typed bookkeeping errors through + // extractBookkeepingDetails, which places account_numbers under details. + expect(body.error.details.account_numbers).toEqual(['5410']) + // Engine and transaction-update must NOT run — the row stays in the + // categorization queue so the user can re-activate and retry. + expect(createTxJE).not.toHaveBeenCalled() + }) + + it('returns 400 ACCOUNTS_NOT_IN_CHART when the engine throws mid-flight (defense in depth)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { + data: { + id: TX_ID, + company_id: COMPANY_ID, + date: '2026-05-12', + amount: -349.5, + currency: 'SEK', + merchant_name: 'ICA', + journal_entry_id: 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 }, + }), + ) + // Pre-validation passes — race condition where an account got + // deactivated between our chart_of_accounts read and the engine's + // resolveAccountIds read. The engine throws and the catch in the route + // must short-circuit to a structured 400 rather than falling through + // to the partial-success branch that would mark the row bokförd with + // no verifikation. + findMissingAccountsMock.mockResolvedValueOnce([]) + const { AccountsNotInChartError } = await import('@/lib/bookkeeping/errors') + createTxJE.mockRejectedValueOnce(new AccountsNotInChartError(['5410'])) + + const res = await categorizePOST( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`, + { is_business: true, category: 'expense_office' }, + ), + txParams(TX_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART') + expect(body.error.details.account_numbers).toEqual(['5410']) + }) }) describe('POST :id/uncategorize', () => { diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index 52fdd610..a7a9a08f 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -34,7 +34,8 @@ import { import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { reverseEntry } from '@/lib/bookkeeping/engine' import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' -import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' +import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation' import { getErrorMessage } from '@/lib/errors/get-error-message' import { eventBus } from '@/lib/events' import type { @@ -250,6 +251,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string .select('account_number, account_class') .eq('company_id', ctx.companyId!) .eq('account_number', body.account_override) + .eq('is_active', true) .single() if (!accountExists) { return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_ACCOUNT', txLog, { @@ -285,6 +287,25 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // Pre-validate every account in the mapping against the company's + // chart_of_accounts. Template / counterparty-template / category paths + // all bypass the older account_override check; without this catch they + // would reach the engine and throw AccountsNotInChartError mid-flight, + // leaving the legacy partial-success branch to silently mark the row as + // bokförd with no verifikation. We validate in both live AND dry-run + // paths so previews surface the same actionable error. + const missingAccounts = await findMissingActiveAccounts( + ctx.supabase, + ctx.companyId!, + collectMappingResultAccounts(mappingResult), + ) + if (missingAccounts.length > 0) { + txLog.warn('mapping references inactive/missing accounts', { missingAccounts }) + return v1ErrorResponse(new AccountsNotInChartError(missingAccounts), txLog, { + requestId: ctx.requestId, + }) + } + // Dry-run stops here — caller sees the resolved mapping without burning // a voucher number or mutating any state. if (ctx.dryRun) { @@ -341,6 +362,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string if (journalEntry) journalEntryId = journalEntry.id } catch (err) { txLog.error('transactions.categorize: journal entry creation failed', err as Error) + // AccountsNotInChartError means an account was deactivated between our + // pre-validation and the engine call (race). Don't fall through to the + // partial-success path that would mark the row bokförd with no + // verifikation — return a structured 400 so the row stays in the + // categorization queue and the caller can retry after re-activating. + if (err instanceof AccountsNotInChartError) { + return v1ErrorResponse(err, txLog, { requestId: ctx.requestId }) + } if (isBookkeepingError(err)) { journalEntryError = getErrorMessage(err, { context: 'transaction' }) } else { diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts new file mode 100644 index 00000000..575e35b9 --- /dev/null +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts @@ -0,0 +1,214 @@ +/** + * Integration tests for POST /api/v1/companies/{companyId}/transactions/batch-categorize. + * + * Covers the missing-account guard: when a categorization references an + * account that isn't active in the company's kontoplan, the per-item result + * must surface as ACCOUNTS_NOT_IN_CHART without ever marking the row bokförd. + * Other items in the same batch continue independently (partial-success + * semantics). + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required') + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +const { createTxJE, findMissingAccountsMock } = vi.hoisted(() => ({ + createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }), + // Default: every mapped account exists and is active. Per-test overrides + // simulate the bug surface. + findMissingAccountsMock: vi.fn().mockResolvedValue([]), +})) + +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: createTxJE, +})) +vi.mock('@/lib/bookkeeping/engine', () => ({ + reverseEntry: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/lib/bookkeeping/account-validation', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/account-validation', + ) + return { + ...actual, + findMissingActiveAccounts: findMissingAccountsMock, + } +}) +// category mapping is real — gives the route real BAS accounts to validate. + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { POST } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const TX_A = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const TX_B = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' + +function makeRequest(url: string, body: unknown): Request { + return new Request(url, { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab', + }, + body: JSON.stringify(body), + }) +} +function batchParams() { + return { params: Promise.resolve({ companyId: COMPANY_ID }) } +} + +beforeEach(() => { + vi.clearAllMocks() + findMissingAccountsMock.mockResolvedValue([]) + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + scopes: ['transactions:write'], + mode: 'live', + }) +}) + +describe('POST batch-categorize', () => { + it('returns per-item ACCOUNTS_NOT_IN_CHART for items whose mapping references inactive accounts; clean items still succeed', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + // Each `transactions` lookup returns the same shape; the flexible + // proxy serves both items from this single result. amount is < 0 so + // both map to an expense flow. + transactions: { + data: { + company_id: COMPANY_ID, + date: '2026-05-12', + amount: -349.5, + currency: 'SEK', + merchant_name: 'ICA', + journal_entry_id: 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 }, + }), + ) + + // First item: mapping references an inactive account. Second item: clean. + findMissingAccountsMock + .mockResolvedValueOnce(['5410']) + .mockResolvedValueOnce([]) + + 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].request_index).toBe(0) + expect(body.data.results[0].error.code).toBe('ACCOUNTS_NOT_IN_CHART') + expect(body.data.results[0].error.details.account_numbers).toEqual(['5410']) + expect(body.data.results[1].ok).toBe(true) + expect(body.data.results[1].request_index).toBe(1) + expect(body.data.summary).toEqual({ total: 2, succeeded: 1, failed: 1 }) + + // Engine must only be called for the clean item. + expect(createTxJE).toHaveBeenCalledTimes(1) + }) + + it('returns ACCOUNTS_NOT_IN_CHART when the engine throws AccountsNotInChartError mid-flight (defense in depth)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: { + data: { + company_id: COMPANY_ID, + date: '2026-05-12', + amount: -349.5, + currency: 'SEK', + merchant_name: 'ICA', + journal_entry_id: 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 }, + }), + ) + // Pre-validation passes — race where an account got deactivated between + // our chart_of_accounts read and the engine's resolveAccountIds read. + findMissingAccountsMock.mockResolvedValueOnce([]) + const { AccountsNotInChartError } = await import('@/lib/bookkeeping/errors') + createTxJE.mockRejectedValueOnce(new AccountsNotInChartError(['5410'])) + + 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' } }, + ], + }, + ), + batchParams(), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.results).toHaveLength(1) + expect(body.data.results[0].ok).toBe(false) + expect(body.data.results[0].error.code).toBe('ACCOUNTS_NOT_IN_CHART') + expect(body.data.results[0].error.details.account_numbers).toEqual(['5410']) + expect(body.data.summary).toEqual({ total: 1, succeeded: 0, failed: 1 }) + }) +}) diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index cd32d041..e71dd677 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -26,7 +26,8 @@ import { } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { reverseEntry } from '@/lib/bookkeeping/engine' -import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' +import { collectMappingResultAccounts, findMissingActiveAccounts } from '@/lib/bookkeeping/account-validation' import { getErrorMessage } from '@/lib/errors/get-error-message' import { eventBus } from '@/lib/events' import type { Logger } from '@/lib/logger' @@ -197,6 +198,31 @@ async function categorizeOne( } } + // Pre-validate every account in the mapping against the company's + // chart_of_accounts. Templates and category defaults can reference accounts + // that aren't activated in this company's kontoplan; without this check the + // engine throws AccountsNotInChartError mid-flight and the legacy + // partial-success branch silently marks the row bokförd with no + // verifikation. Validate in both dry-run and live paths so previews + // surface the same actionable error. + const missingAccounts = await findMissingActiveAccounts( + supabase, + companyId, + collectMappingResultAccounts(mappingResult), + ) + if (missingAccounts.length > 0) { + return { + ok: false, + request_index: index, + transaction_id: transactionId, + error: { + code: 'ACCOUNTS_NOT_IN_CHART', + message: `Följande konton behöver aktiveras: ${missingAccounts.join(', ')}`, + details: { account_numbers: missingAccounts }, + }, + } + } + if (dryRun) { return { ok: true, @@ -279,6 +305,23 @@ async function categorizeOne( request_index: index, transactionId, }) + // AccountsNotInChartError means an account was deactivated between our + // pre-validation and the engine call (rare race). Return the per-item + // failure WITHOUT the transaction update below so the row stays in + // "Att bokföra" — partial-success on a missing-account error would + // mark it bokförd with no verifikation. + if (err instanceof AccountsNotInChartError) { + return { + ok: false, + request_index: index, + transaction_id: transactionId, + error: { + code: 'ACCOUNTS_NOT_IN_CHART', + message: `Följande konton behöver aktiveras: ${err.accountNumbers.join(', ')}`, + details: { account_numbers: err.accountNumbers }, + }, + } + } if (isBookkeepingError(err)) { journalEntryError = getErrorMessage(err, { context: 'transaction' }) } else { diff --git a/app/api/v1/companies/[companyId]/transactions/ingest/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/ingest/__tests__/route.test.ts index 65027bec..2b3363a2 100644 --- a/app/api/v1/companies/[companyId]/transactions/ingest/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/ingest/__tests__/route.test.ts @@ -19,7 +19,7 @@ vi.mock('@supabase/supabase-js', async () => { return { ...actual, createClient: vi.fn().mockReturnValue({}) } }) -const { ingestMock, createTxJE } = vi.hoisted(() => ({ +const { ingestMock, createTxJE, findMissingAccountsMock } = vi.hoisted(() => ({ ingestMock: vi.fn().mockResolvedValue({ imported: 2, duplicates: 1, @@ -30,6 +30,12 @@ const { ingestMock, createTxJE } = vi.hoisted(() => ({ transaction_ids: ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'], }), createTxJE: vi.fn().mockResolvedValue({ id: 'je-bc' }), + // batch-categorize pre-validates every mapped account against chart_of_accounts + // (commit 6afb13aa). The flexible-Supabase proxy returns { data: null } for + // unmocked tables, which would make the real implementation report ALL + // accounts as missing and short-circuit every item with ACCOUNTS_NOT_IN_CHART. + // Stub it to "no missing accounts" so the happy path is exercised. + findMissingAccountsMock: vi.fn().mockResolvedValue([]), })) vi.mock('@/lib/transactions/ingest', () => ({ @@ -38,6 +44,15 @@ vi.mock('@/lib/transactions/ingest', () => ({ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ createTransactionJournalEntry: createTxJE, })) +vi.mock('@/lib/bookkeeping/account-validation', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/account-validation', + ) + return { + ...actual, + findMissingActiveAccounts: findMissingAccountsMock, + } +}) import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' import { POST as ingestPOST } from '../route' diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index 5b80671d..417707ac 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -229,6 +229,11 @@ export default function QuickReviewDialog({ setShowUploadZone(false) } catch { setError('Ett fel uppstod vid bokföring.') + } finally { + // Always reset isProcessing — without this, an onConfirm that resolves + // with null (e.g. server returned a structured 4xx error like + // ACCOUNTS_NOT_IN_CHART) leaves the dialog frozen because the + // below disables backdrop/ESC while processing. setIsProcessing(false) } } diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 45124371..8ad078dc 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -6819,14 +6819,15 @@ export async function handleMcpRequest(request: Request): Promise { '• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first — domain workflows are documented as loadable skills with tool references.', '', 'Common workflows:', - '• Categorize transactions: gnubok_list_uncategorized_transactions → gnubok_suggest_categories → gnubok_categorize_transaction (stages for user approval). Use gnubok_match_transaction_to_invoice to apply income to a specific invoice.', + '• Categorize transactions: gnubok_list_uncategorized_transactions → gnubok_suggest_categories → gnubok_categorize_transaction (stages) → gnubok_approve_pending_operation (after user confirms in chat). Use gnubok_match_transaction_to_invoice to apply income to a specific invoice.', '• Invoicing: gnubok_list_customers (or gnubok_create_customer) → gnubok_create_invoice → gnubok_send_invoice or gnubok_mark_invoice_as_sent → gnubok_mark_invoice_as_paid. Refund via gnubok_credit_invoice.', '• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative).', '• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger — all default to the most recent fiscal period.', '• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.', '• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → review/approve in web UI → gnubok_generate_agi.', + '• Reviewing & approving staged operations: gnubok_list_pending_operations (filter status / risk_level / operation_type) shows what is staged. After the user reviews and explicitly authorizes in chat, call gnubok_approve_pending_operation to commit (high-risk ops require confirmed=true). Use gnubok_reject_pending_operation to discard.', '', - 'Write operations stage a pending_operation (risk_level: low/medium/high) — the user approves in the gnubok web app before any DB write. Pass dry_run=true to preview without staging. Pass idempotency_key to make a write safely retryable.', + 'Write operations stage a pending_operation (risk_level: low/medium/high). Nothing posts until the user approves. Two equally valid approval paths: (a) the user explicitly authorizes in chat ("approve", "yes go ahead", "commit it") → call gnubok_approve_pending_operation, or (b) the user reviews and approves later in the gnubok web app at /pending. The chat-approval path is the intended flow for conversational use — do not refuse it on segregation-of-duties grounds when the user has explicitly authorized commitment. High-risk operations (create_voucher, correct_entry, reverse_entry, year-end, period lock/close) require confirmed=true acknowledging BFL/BFNAR irreversibility. Pass dry_run=true to preview without staging. Pass idempotency_key to make a write safely retryable.', 'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").', ].join('\n'), }) diff --git a/lib/bookkeeping/__tests__/errors.test.ts b/lib/bookkeeping/__tests__/errors.test.ts index 43c31572..25c709d3 100644 --- a/lib/bookkeeping/__tests__/errors.test.ts +++ b/lib/bookkeeping/__tests__/errors.test.ts @@ -26,6 +26,53 @@ describe('Typed bookkeeping errors', () => { expect(err).toBeInstanceOf(Error) }) + it('AccountsNotInChartError ordering is deterministic across input permutations', () => { + // The user-facing toast lists which accounts to activate in Kontoplan. + // If the same set of missing accounts came back in different orders on + // each call, the user might mistake an identical error for a different + // one. Lock in: same set → same array, regardless of input order. + const sets: string[][] = [ + ['5410', '2641'], + ['2641', '5410'], + ['5410', '5410', '2641', '2641'], + ['2641', '2641', '5410'], + ] + const outputs = sets.map((s) => new AccountsNotInChartError(s).accountNumbers) + for (const out of outputs) { + expect(out).toEqual(['2641', '5410']) + } + // And the rendered message stays stable too. + expect(new AccountsNotInChartError(['5410', '2641']).message).toBe( + new AccountsNotInChartError(['2641', '5410']).message, + ) + }) + + it('AccountsNotInChartError sorts numerically, not by UTF-16 code units', () => { + // Default string sort would order ['245', '1930'] as ['1930', '245'] + // because '1' < '2'. That's wrong for accounts — a user looking at the + // toast and walking down their kontoplan expects numeric order. + const err = new AccountsNotInChartError(['1930', '245', '5410']) + expect(err.accountNumbers).toEqual(['245', '1930', '5410']) + }) + + it('AccountsNotInChartError tie-breaks numerically-equal strings deterministically', () => { + // "0245" and "245" compare as Number-equal but are distinct strings; the + // comparator must not collapse them by returning 0 unstably. We don't + // emit zero-padded BAS codes anywhere internally, but the public surface + // shouldn't be sensitive to that defensive case. + const err = new AccountsNotInChartError(['245', '0245']) + expect(err.accountNumbers).toEqual(['0245', '245']) + }) + + it('AccountsNotInChartError preserves order across multiple calls with same data', () => { + // Same inputs ⇒ identical outputs across separate calls (no hidden + // randomness, no Set iteration drift, no dependence on call order). + const calls = Array.from({ length: 5 }, () => new AccountsNotInChartError(['5410', '1930', '2641']).accountNumbers) + for (let i = 1; i < calls.length; i++) { + expect(calls[i]).toEqual(calls[0]) + } + }) + it('JournalEntryNotBalancedError preserves amounts and kind', () => { const err = new JournalEntryNotBalancedError(100, 80, 'correction') expect(err.code).toBe('JOURNAL_ENTRY_NOT_BALANCED') diff --git a/lib/bookkeeping/account-validation.ts b/lib/bookkeeping/account-validation.ts new file mode 100644 index 00000000..23bc1c8c --- /dev/null +++ b/lib/bookkeeping/account-validation.ts @@ -0,0 +1,56 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { MappingResult } from '@/types' + +/** + * Return the subset of `accountNumbers` that are NOT present-and-active in the + * given company's chart_of_accounts. Mirrors the engine's resolveAccountIds + * (lib/bookkeeping/engine.ts) so a pre-validation in API routes catches the + * same condition (AccountsNotInChartError) before any DB writes happen. + * + * Empty/duplicate inputs are normalised; preserves first-seen order in output. + * On Supabase error: bubbles up. A chart-of-accounts read failure is real + * infrastructure trouble and should surface as 500 rather than be silently + * masked as "account missing". + */ +export async function findMissingActiveAccounts( + supabase: SupabaseClient, + companyId: string, + accountNumbers: readonly string[], +): Promise { + const seen = new Set() + const unique: string[] = [] + for (const num of accountNumbers) { + if (!num) continue + if (seen.has(num)) continue + seen.add(num) + unique.push(num) + } + if (unique.length === 0) return [] + + const { data, error } = await supabase + .from('chart_of_accounts') + .select('account_number') + .eq('company_id', companyId) + .eq('is_active', true) + .in('account_number', unique) + + if (error) throw error + + const present = new Set((data ?? []).map((r) => r.account_number as string)) + return unique.filter((n) => !present.has(n)) +} + +/** + * Extract every chart account number a MappingResult will post to: the headline + * debit/credit plus every account_number in vat_lines. Returns the raw list + * (duplicates intact); pass through findMissingActiveAccounts to dedupe. + */ +export function collectMappingResultAccounts(mr: MappingResult): string[] { + const out: string[] = [] + if (mr.debit_account) out.push(mr.debit_account) + if (mr.credit_account) out.push(mr.credit_account) + for (const line of mr.vat_lines ?? []) { + if (line.account_number) out.push(line.account_number) + } + return out +} diff --git a/lib/bookkeeping/errors.ts b/lib/bookkeeping/errors.ts index c7347352..3cf2df0d 100644 --- a/lib/bookkeeping/errors.ts +++ b/lib/bookkeeping/errors.ts @@ -25,13 +25,35 @@ export class AccountsNotInChartError extends Error { readonly accountNumbers: string[] constructor(accountNumbers: string[]) { - const sorted = [...new Set(accountNumbers)].sort() + // Numeric-first sort so mixed-length BAS codes (rare but possible) order + // by value rather than by UTF-16 code units — otherwise ['245', '1930'] + // would sort to ['1930', '245'] under the default string comparator, + // confusing a user about which accounts to activate in Kontoplan. + // Non-numeric tokens fall back to a stable string compare so the order + // is fully deterministic for any input. + const sorted = [...new Set(accountNumbers)].sort(compareAccountNumbers) super(`Accounts not enabled in chart of accounts: ${sorted.join(', ')}`) this.name = 'AccountsNotInChartError' this.accountNumbers = sorted } } +function compareAccountNumbers(a: string, b: string): number { + const na = Number(a) + const nb = Number(b) + const aIsNum = Number.isFinite(na) + const bIsNum = Number.isFinite(nb) + if (aIsNum && bIsNum) { + if (na !== nb) return na - nb + // Same numeric value but different string (e.g. "0245" vs "245") — + // break the tie deterministically by string. + return a < b ? -1 : a > b ? 1 : 0 + } + if (aIsNum) return -1 + if (bIsNum) return 1 + return a < b ? -1 : a > b ? 1 : 0 +} + export function isAccountsNotInChartError(err: unknown): err is AccountsNotInChartError { return err instanceof AccountsNotInChartError } @@ -203,7 +225,12 @@ export function accountsNotInChartResponse(err: AccountsNotInChartError) { error: { code: err.code, message: `Följande konton behöver aktiveras: ${err.accountNumbers.join(', ')}`, + // Dual-emit: top-level for legacy frontend callers, nested under + // `details` to match the v1 envelope shape so a single client (MCP + // or external) can read `error.details.account_numbers` regardless + // of which categorize endpoint it hit. account_numbers: err.accountNumbers, + details: { account_numbers: err.accountNumbers }, }, }, { status: 400 }