Refactor bookkeeping error handling and introduce new error classes (#356)

- Introduced new error classes for better error categorization:
  - JournalEntryNotBalancedError
  - FiscalPeriodNotFoundError
  - EntryDateOutsideFiscalPeriodError
  - JournalEntryNotFoundError
  - CannotReverseNonPostedError
  - CannotCorrectNonPostedError
  - EntryAlreadyReversedError
  - CurrencyRevaluationAlreadyExistsError
  - InvalidMappingResultError
  - BookkeepingDatabaseError

- Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones.
- Enhanced error response handling in get-error-message.ts to provide localized messages for new error types.
- Added unit tests for new error classes and error handling functions to ensure correctness and coverage.
This commit is contained in:
Mattsson
2026-04-23 14:49:45 +02:00
committed by GitHub
parent e137a9f452
commit 0222e084bb
32 changed files with 944 additions and 126 deletions
@@ -4,6 +4,7 @@ import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
@@ -40,6 +41,8 @@ export async function GET(
const preview = await previewCurrencyRevaluation(supabase, companyId, period.period_end)
return NextResponse.json({ data: preview })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to preview currency revaluation' },
{ status: 400 }
@@ -92,6 +95,8 @@ export async function POST(
return NextResponse.json({ data: result })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to execute currency revaluation' },
{ status: 400 }
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
@@ -35,9 +35,8 @@ export async function POST(
const result = await correctEntry(supabase, companyId, user.id, id, body.lines)
return NextResponse.json({ data: result })
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to correct entry' },
{ status: 400 }
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
@@ -29,9 +29,8 @@ export async function POST(
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
return NextResponse.json({ data: reversalEntry })
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to reverse entry' },
{ status: 400 }
+3 -4
View File
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
@@ -124,9 +124,8 @@ export async function POST(request: Request) {
const entry = await createJournalEntry(supabase, companyId, user.id, body)
return NextResponse.json({ data: entry })
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
{ status: 400 }
@@ -6,7 +6,7 @@ import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas'
import { requireWritePermission } from '@/lib/auth/require-write'
import { requireCompanyId } from '@/lib/company/context'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { CreateJournalEntryLineInput } from '@/types'
@@ -242,9 +242,8 @@ export async function POST(request: Request) {
},
})
} catch (error) {
if (error instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(error)
}
const typed = bookkeepingErrorResponse(error)
if (typed) return typed
console.error('Opening balance execute error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Importen misslyckades' },
+3 -4
View File
@@ -5,7 +5,7 @@ import {
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
@@ -161,9 +161,8 @@ export async function POST(
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
console.error('Failed to create payment journal entry:', err)
return NextResponse.json(
{ error: 'Kunde inte bokföra betalningen' },
@@ -16,7 +16,11 @@ import {
createInvoiceJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import {
AccountsNotInChartError,
bookkeepingErrorResponse,
isBookkeepingError,
} from '@/lib/bookkeeping/errors'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -220,7 +224,7 @@ async function commitCategorizeTransaction(
journalEntryId = journalEntry.id
}
} catch (err) {
if (err instanceof AccountsNotInChartError) throw err
if (isBookkeepingError(err)) throw err
log.error('Failed to create journal entry:', err)
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
}
@@ -787,7 +791,7 @@ async function commitMatchTransactionInvoice(
journalEntryId = je?.id ?? null
}
} catch (err) {
if (err instanceof AccountsNotInChartError) throw err
if (isBookkeepingError(err)) throw err
log.error('Failed to create match journal entry:', err)
}
@@ -915,9 +919,8 @@ export async function POST(
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
throw err
}
+5 -7
View File
@@ -4,7 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors'
ensureInitialized()
@@ -62,14 +62,12 @@ export async function POST(
try {
await reverseEntry(supabase, companyId, user.id, entryId)
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
// Entry may already be reversed — continue
if (err instanceof EntryAlreadyReversedError) continue
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
const msg = err instanceof Error ? err.message : ''
if (!msg.includes('already reversed')) {
return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 })
}
return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 })
}
}
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
@@ -138,9 +138,8 @@ export async function POST(
// momsdeklaration-integrity concern as the POST-route rollback.
await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId)
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
console.error('Failed to create credit note journal entry:', err)
return NextResponse.json(
{ error: 'Kunde inte bokföra kreditfakturan — försök igen eller ändra datum om perioden är låst.' },
@@ -6,7 +6,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
@@ -99,9 +99,8 @@ export async function POST(
if (journalEntry) journalEntryId = journalEntry.id
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
console.error('Failed to create payment journal entry:', err)
return NextResponse.json(
{ error: 'Kunde inte bokföra betalningen' },
@@ -30,6 +30,8 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
import { CannotReverseNonPostedError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
import { POST } from '../route'
@@ -222,7 +224,7 @@ describe('POST /api/supplier-invoices/[id]/uncredit', () => {
data: { id: 'credit-1', registration_journal_entry_id: 'je-credit' },
error: null,
})
mockReverseEntry.mockRejectedValue(new Error('Can only reverse posted entries'))
mockReverseEntry.mockRejectedValue(new CannotReverseNonPostedError('draft'))
enqueue({ data: null, error: null })
enqueue({
data: { ...original, status: 'approved', remaining_amount: 5000 },
@@ -3,7 +3,11 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import {
bookkeepingErrorResponse,
CannotReverseNonPostedError,
EntryAlreadyReversedError,
} from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
@@ -69,16 +73,12 @@ export async function POST(
)
reversalEntryId = reversal.id
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Already reversed (manually or by another concurrent uncredit) — fine, continue.
if (
/Can only reverse posted entries/i.test(msg) ||
/already reversed by a concurrent operation/i.test(msg)
) {
if (err instanceof CannotReverseNonPostedError || err instanceof EntryAlreadyReversedError) {
// proceed to row cleanup
} else if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
} else {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
// Period lock and similar trigger errors — surface a clear Swedish message
// so the user knows WHY the action failed (per project's error-UX guidelines).
return NextResponse.json(
+3 -4
View File
@@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
@@ -272,9 +272,8 @@ export async function POST(request: Request) {
// which silently understates the momsdeklaration for the period.
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
console.error('Failed to create registration journal entry:', err)
return NextResponse.json(
{ error: 'Kunde inte bokföra leverantörsfakturan — försök igen eller ändra datum om perioden är låst.' },
+3 -4
View File
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { BookTransactionSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
@@ -66,9 +66,8 @@ export async function POST(
lines,
})
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
{ status: 400 }
+11 -1
View File
@@ -5,6 +5,8 @@ import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { requireCompanyId } from '@/lib/company/context'
@@ -297,7 +299,15 @@ export async function POST(
}
} catch (err) {
console.error('Failed to create journal entry:', err)
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
// Typed bookkeeping errors: surface a Swedish translation in the response
// so the client toast can display it directly.
const typedResp = bookkeepingErrorResponse(err)
if (typedResp) {
const body = (await typedResp.json()) as unknown
journalEntryError = getErrorMessage(body, { context: 'transaction' })
} else {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// Continue - we still want to save the categorization
}
@@ -5,7 +5,12 @@ import {
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import {
AccountsNotInChartError,
accountsNotInChartResponse,
bookkeepingErrorResponse,
} from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { validateBody } from '@/lib/api/validate'
import { MatchInvoiceSchema } from '@/lib/api/schemas'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -122,9 +127,8 @@ export async function POST(
newState: { journal_entry_id: null },
})
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
console.error('Failed to storno conflicting journal entry:', err)
return NextResponse.json(
{ error: 'Failed to reverse conflicting journal entry' },
@@ -204,11 +208,21 @@ export async function POST(
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
// AccountsNotInChart returns the structured 400 so the UI can open the
// account-activation dialog. Other errors are logged and attached to
// `journal_entry_error` — the match itself is a valuable business event,
// and the user can re-book the payment verifikation separately.
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
console.error('Failed to create payment journal entry:', err)
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
const typedResp = bookkeepingErrorResponse(err)
if (typedResp) {
const body = (await typedResp.json()) as unknown
journalEntryError = getErrorMessage(body, { context: 'invoice' })
} else {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// Continue - we still want to update the invoice and transaction
}
@@ -4,7 +4,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -128,9 +128,8 @@ export async function POST(
if (journalEntry) journalEntryId = journalEntry.id
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
console.error('Failed to create payment journal entry:', err)
}
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
@@ -61,9 +61,8 @@ export async function POST(
try {
await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id)
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
const message = err instanceof Error ? err.message : 'Reversal failed'
return NextResponse.json({ error: message }, { status: 500 })
}