Bug/mcp bas lag (#543)
* fix(mcp): update workflow descriptions for transaction categorization and approval processes * feat: implement account validation for transaction categorization to handle inactive accounts * fix(tests): stub findMissingAccountsMock to ensure no missing accounts during batch-categorize tests * fix(errors): ensure deterministic sorting of account numbers in AccountsNotInChartError
This commit is contained in:
@@ -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 ? (
|
||||
<ToastAction altText="Aktivera och bokför" onClick={async () => {
|
||||
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
|
||||
</ToastAction>
|
||||
) : 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 ? (
|
||||
<ToastAction altText="Aktivera och bokför" onClick={async () => {
|
||||
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
|
||||
</ToastAction>
|
||||
) : 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof import('@/lib/bookkeeping/account-validation')>(
|
||||
'@/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.
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof import('@/lib/bookkeeping/account-validation')>(
|
||||
'@/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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+214
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@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<typeof import('@/lib/bookkeeping/account-validation')>(
|
||||
'@/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<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof import('@/lib/bookkeeping/account-validation')>(
|
||||
'@/lib/bookkeeping/account-validation',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
findMissingActiveAccounts: findMissingAccountsMock,
|
||||
}
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as ingestPOST } from '../route'
|
||||
|
||||
@@ -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
|
||||
// <Dialog onOpenChange> below disables backdrop/ESC while processing.
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6819,14 +6819,15 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
'• 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'),
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string[]> {
|
||||
const seen = new Set<string>()
|
||||
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<string>((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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user