Files
accounted/lib/bookkeeping/errors.ts
T
Mattsson 0222e084bb 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.
2026-04-23 14:49:45 +02:00

347 lines
11 KiB
TypeScript

import { NextResponse } from 'next/server'
// ============================================================================
// Error codes
// ============================================================================
export const ACCOUNTS_NOT_IN_CHART = 'ACCOUNTS_NOT_IN_CHART' as const
export const JOURNAL_ENTRY_NOT_BALANCED = 'JOURNAL_ENTRY_NOT_BALANCED' as const
export const FISCAL_PERIOD_NOT_FOUND = 'FISCAL_PERIOD_NOT_FOUND' as const
export const ENTRY_DATE_OUTSIDE_FISCAL_PERIOD = 'ENTRY_DATE_OUTSIDE_FISCAL_PERIOD' as const
export const JOURNAL_ENTRY_NOT_FOUND = 'JOURNAL_ENTRY_NOT_FOUND' as const
export const CANNOT_REVERSE_NON_POSTED = 'CANNOT_REVERSE_NON_POSTED' as const
export const CANNOT_CORRECT_NON_POSTED = 'CANNOT_CORRECT_NON_POSTED' as const
export const ENTRY_ALREADY_REVERSED = 'ENTRY_ALREADY_REVERSED' as const
export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY_EXISTS' as const
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
export const BOOKKEEPING_DATABASE_ERROR = 'BOOKKEEPING_DATABASE_ERROR' as const
// ============================================================================
// AccountsNotInChartError — kept for back-compat (many existing call sites)
// ============================================================================
export class AccountsNotInChartError extends Error {
readonly code = ACCOUNTS_NOT_IN_CHART
readonly accountNumbers: string[]
constructor(accountNumbers: string[]) {
const sorted = [...new Set(accountNumbers)].sort()
super(`Accounts not enabled in chart of accounts: ${sorted.join(', ')}`)
this.name = 'AccountsNotInChartError'
this.accountNumbers = sorted
}
}
export function isAccountsNotInChartError(err: unknown): err is AccountsNotInChartError {
return err instanceof AccountsNotInChartError
}
// ============================================================================
// Semantic errors — carry structured data so getErrorMessage can format rich
// Swedish translations with amounts / period names / status.
// ============================================================================
export class JournalEntryNotBalancedError extends Error {
readonly code = JOURNAL_ENTRY_NOT_BALANCED
constructor(
public readonly totalDebit: number,
public readonly totalCredit: number,
public readonly kind: 'draft' | 'correction' = 'draft'
) {
super(`Journal entry is not balanced: debits (${totalDebit}) != credits (${totalCredit})`)
this.name = 'JournalEntryNotBalancedError'
}
}
export class FiscalPeriodNotFoundError extends Error {
readonly code = FISCAL_PERIOD_NOT_FOUND
constructor() {
super('Fiscal period not found')
this.name = 'FiscalPeriodNotFoundError'
}
}
export class EntryDateOutsideFiscalPeriodError extends Error {
readonly code = ENTRY_DATE_OUTSIDE_FISCAL_PERIOD
constructor(
public readonly entryDate: string,
public readonly periodName: string,
public readonly periodStart: string,
public readonly periodEnd: string
) {
super(
`Entry date ${entryDate} is outside fiscal period "${periodName}" (${periodStart} - ${periodEnd})`
)
this.name = 'EntryDateOutsideFiscalPeriodError'
}
}
export class JournalEntryNotFoundError extends Error {
readonly code = JOURNAL_ENTRY_NOT_FOUND
constructor() {
super('Journal entry not found')
this.name = 'JournalEntryNotFoundError'
}
}
export class CannotReverseNonPostedError extends Error {
readonly code = CANNOT_REVERSE_NON_POSTED
constructor(public readonly currentStatus: string) {
super('Can only reverse posted entries')
this.name = 'CannotReverseNonPostedError'
}
}
export class CannotCorrectNonPostedError extends Error {
readonly code = CANNOT_CORRECT_NON_POSTED
constructor(public readonly currentStatus: string) {
super('Can only correct posted entries')
this.name = 'CannotCorrectNonPostedError'
}
}
export class EntryAlreadyReversedError extends Error {
readonly code = ENTRY_ALREADY_REVERSED
constructor() {
super('Entry was already reversed by a concurrent operation')
this.name = 'EntryAlreadyReversedError'
}
}
export class CurrencyRevaluationAlreadyExistsError extends Error {
readonly code = CURRENCY_REVALUATION_ALREADY_EXISTS
constructor() {
super('Currency revaluation already exists for this period')
this.name = 'CurrencyRevaluationAlreadyExistsError'
}
}
export class InvalidMappingResultError extends Error {
readonly code = INVALID_MAPPING_RESULT
constructor(
public readonly debitAccount: string | null | undefined,
public readonly creditAccount: string | null | undefined
) {
super(
`Invalid mapping result: debit_account="${debitAccount}", credit_account="${creditAccount}". Both must be non-empty.`
)
this.name = 'InvalidMappingResultError'
}
}
// ============================================================================
// BookkeepingDatabaseError — single wrapper for all "Failed to <op>: <cause>"
// engine throws. The `operation` tag is preserved for logs; the cause string
// stays in `message` so period-lock / trigger messages can still be matched
// by regex patterns in get-error-message.ts.
// ============================================================================
export type BookkeepingOperation =
| 'get_next_voucher_number'
| 'resolve_account_ids'
| 'create_draft_entry'
| 'create_entry_lines'
| 'commit_entry'
| 'create_reversal_entry'
| 'create_reversal_lines'
| 'post_reversal_entry'
| 'create_corrected_entry'
| 'create_corrected_lines'
| 'post_corrected_entry'
| 'fetch_currency_receivables'
| 'fetch_currency_payables'
| 'check_existing_revaluation'
export class BookkeepingDatabaseError extends Error {
readonly code = BOOKKEEPING_DATABASE_ERROR
constructor(
public readonly operation: BookkeepingOperation,
public readonly cause: string | undefined
) {
super(cause ? `Database operation "${operation}" failed: ${cause}` : `Database operation "${operation}" failed`)
this.name = 'BookkeepingDatabaseError'
}
}
// ============================================================================
// Type guard
// ============================================================================
/**
* True if `err` is any typed bookkeeping error. Use this in inner catch blocks
* that want to re-throw domain errors so the outer handler can translate them
* via bookkeepingErrorResponse().
*/
export function isBookkeepingError(err: unknown): boolean {
return (
err instanceof AccountsNotInChartError ||
err instanceof JournalEntryNotBalancedError ||
err instanceof FiscalPeriodNotFoundError ||
err instanceof EntryDateOutsideFiscalPeriodError ||
err instanceof JournalEntryNotFoundError ||
err instanceof CannotReverseNonPostedError ||
err instanceof CannotCorrectNonPostedError ||
err instanceof EntryAlreadyReversedError ||
err instanceof CurrencyRevaluationAlreadyExistsError ||
err instanceof InvalidMappingResultError ||
err instanceof BookkeepingDatabaseError
)
}
// ============================================================================
// Response helpers
// ============================================================================
/**
* Build a structured 400 response for AccountsNotInChartError.
* Kept for back-compat with existing callers; new code should prefer
* bookkeepingErrorResponse() which covers all typed bookkeeping errors.
*/
export function accountsNotInChartResponse(err: AccountsNotInChartError) {
return NextResponse.json(
{
error: {
code: err.code,
message: `Följande konton behöver aktiveras: ${err.accountNumbers.join(', ')}`,
account_numbers: err.accountNumbers,
},
},
{ status: 400 }
)
}
/**
* Build a structured JSON response for any typed bookkeeping error.
* Returns null if `err` is not a recognized bookkeeping error so callers can
* fall through to their existing generic handling.
*
* Response shape: { error: { code, message, details? } }
* HTTP status: 404 for *_NOT_FOUND, 409 for concurrent/duplicate conflicts,
* 500 for BOOKKEEPING_DATABASE_ERROR, 400 otherwise.
*/
export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
if (err instanceof JournalEntryNotBalancedError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: {
totalDebit: err.totalDebit,
totalCredit: err.totalCredit,
kind: err.kind,
},
},
},
{ status: 400 }
)
}
if (err instanceof FiscalPeriodNotFoundError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },
{ status: 404 }
)
}
if (err instanceof EntryDateOutsideFiscalPeriodError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: {
entryDate: err.entryDate,
periodName: err.periodName,
periodStart: err.periodStart,
periodEnd: err.periodEnd,
},
},
},
{ status: 400 }
)
}
if (err instanceof JournalEntryNotFoundError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },
{ status: 404 }
)
}
if (err instanceof CannotReverseNonPostedError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { currentStatus: err.currentStatus },
},
},
{ status: 400 }
)
}
if (err instanceof CannotCorrectNonPostedError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { currentStatus: err.currentStatus },
},
},
{ status: 400 }
)
}
if (err instanceof EntryAlreadyReversedError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },
{ status: 409 }
)
}
if (err instanceof CurrencyRevaluationAlreadyExistsError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },
{ status: 409 }
)
}
if (err instanceof InvalidMappingResultError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: {
debitAccount: err.debitAccount,
creditAccount: err.creditAccount,
},
},
},
{ status: 400 }
)
}
if (err instanceof BookkeepingDatabaseError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { operation: err.operation },
},
},
{ status: 500 }
)
}
return null
}