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
+11 -2
View File
@@ -30,6 +30,7 @@ import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeepi
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
@@ -319,7 +320,11 @@ export default function TransactionsPage() {
const result = await response.json()
if (!response.ok) {
toast({ title: 'Kategorisering misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' })
toast({
title: 'Kategorisering misslyckades',
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
variant: 'destructive',
})
setProcessingId(null)
return null
}
@@ -348,7 +353,11 @@ export default function TransactionsPage() {
toast({ title: 'Ångrad', description: 'Kategorisering har ångrats' })
} else {
const errData = await undoRes.json()
toast({ title: 'Kunde inte ångra', description: errData.error || 'Kategoriseringen kunde inte ångras. Försök igen.', variant: 'destructive' })
toast({
title: 'Kunde inte ångra',
description: getErrorMessage(errData, { context: 'transaction', statusCode: undoRes.status }),
variant: 'destructive',
})
}
} catch {
toast({ title: 'Kunde inte ångra', description: 'Kategoriseringen kunde inte ångras. Försök igen.', variant: 'destructive' })
@@ -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 })
}
@@ -15,6 +15,7 @@ import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Plus, Trash2 } from 'lucide-react'
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
@@ -109,7 +110,10 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
const result = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to create correction')
const error = new Error('Failed to create correction') as Error & { body?: unknown; status?: number }
error.body = result
error.status = res.status
throw error
}
const correctedId = result.data?.corrected?.id
@@ -126,9 +130,10 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
onOpenChange(false)
onCorrected()
} catch (err) {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Fel',
description: err instanceof Error ? err.message : 'Kunde inte skapa ändringsverifikation',
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
} finally {
+7 -2
View File
@@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { proposePaymentLines } from '@/lib/bookkeeping/propose-payment-lines'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
@@ -187,15 +188,19 @@ export default function PaymentBookingDialog({
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte markera som betald')
const error = new Error('Kunde inte markera som betald') as Error & { body?: unknown; status?: number }
error.body = data
error.status = response.status
throw error
}
onOpenChange(false)
onSuccess()
} catch (error) {
const anyErr = error as { body?: unknown; status?: number }
toast({
title: 'Bokföring misslyckades',
description: error instanceof Error ? error.message : 'Försök igen.',
description: getErrorMessage(anyErr.body ?? error, { context: 'invoice', statusCode: anyErr.status }),
variant: 'destructive',
})
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine'
import { BookkeepingDatabaseError } from '../errors'
import type { CreateJournalEntryLineInput, JournalEntryStatus } from '@/types'
// Mock Supabase client for createDraftEntry/reverseEntry tests
@@ -167,7 +168,7 @@ describe('createDraftEntry — cancelled status on line-insert failure', () => {
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
],
})
).rejects.toThrow('Failed to create journal entry lines')
).rejects.toThrow(BookkeepingDatabaseError)
// Should call update with cancelled status, NOT delete
expect(updateMock).toHaveBeenCalledWith({ status: 'cancelled' })
+258
View File
@@ -0,0 +1,258 @@
import { describe, it, expect } from 'vitest'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotCorrectNonPostedError,
CannotReverseNonPostedError,
CurrencyRevaluationAlreadyExistsError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
InvalidMappingResultError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
accountsNotInChartResponse,
bookkeepingErrorResponse,
isAccountsNotInChartError,
isBookkeepingError,
} from '../errors'
describe('Typed bookkeeping errors', () => {
it('AccountsNotInChartError carries sorted, deduped account numbers', () => {
const err = new AccountsNotInChartError(['2641', '1930', '1930', '1510'])
expect(err.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(err.name).toBe('AccountsNotInChartError')
expect(err.accountNumbers).toEqual(['1510', '1930', '2641'])
expect(err).toBeInstanceOf(Error)
})
it('JournalEntryNotBalancedError preserves amounts and kind', () => {
const err = new JournalEntryNotBalancedError(100, 80, 'correction')
expect(err.code).toBe('JOURNAL_ENTRY_NOT_BALANCED')
expect(err.totalDebit).toBe(100)
expect(err.totalCredit).toBe(80)
expect(err.kind).toBe('correction')
expect(err.message).toContain('100')
expect(err.message).toContain('80')
})
it('JournalEntryNotBalancedError defaults kind to "draft"', () => {
const err = new JournalEntryNotBalancedError(100, 80)
expect(err.kind).toBe('draft')
})
it('FiscalPeriodNotFoundError has fixed message', () => {
const err = new FiscalPeriodNotFoundError()
expect(err.code).toBe('FISCAL_PERIOD_NOT_FOUND')
expect(err.message).toBe('Fiscal period not found')
})
it('EntryDateOutsideFiscalPeriodError carries period context', () => {
const err = new EntryDateOutsideFiscalPeriodError('2024-06-15', 'FY 2025', '2025-01-01', '2025-12-31')
expect(err.code).toBe('ENTRY_DATE_OUTSIDE_FISCAL_PERIOD')
expect(err.entryDate).toBe('2024-06-15')
expect(err.periodName).toBe('FY 2025')
expect(err.periodStart).toBe('2025-01-01')
expect(err.periodEnd).toBe('2025-12-31')
})
it('JournalEntryNotFoundError has fixed message', () => {
const err = new JournalEntryNotFoundError()
expect(err.code).toBe('JOURNAL_ENTRY_NOT_FOUND')
expect(err.message).toBe('Journal entry not found')
})
it('CannotReverseNonPostedError carries current status', () => {
const err = new CannotReverseNonPostedError('draft')
expect(err.code).toBe('CANNOT_REVERSE_NON_POSTED')
expect(err.currentStatus).toBe('draft')
})
it('CannotCorrectNonPostedError carries current status', () => {
const err = new CannotCorrectNonPostedError('reversed')
expect(err.code).toBe('CANNOT_CORRECT_NON_POSTED')
expect(err.currentStatus).toBe('reversed')
})
it('EntryAlreadyReversedError has fixed message', () => {
const err = new EntryAlreadyReversedError()
expect(err.code).toBe('ENTRY_ALREADY_REVERSED')
})
it('CurrencyRevaluationAlreadyExistsError has fixed message', () => {
const err = new CurrencyRevaluationAlreadyExistsError()
expect(err.code).toBe('CURRENCY_REVALUATION_ALREADY_EXISTS')
})
it('InvalidMappingResultError carries mapping details', () => {
const err = new InvalidMappingResultError(null, '3001')
expect(err.code).toBe('INVALID_MAPPING_RESULT')
expect(err.debitAccount).toBeNull()
expect(err.creditAccount).toBe('3001')
})
it('BookkeepingDatabaseError carries operation tag and cause', () => {
const err = new BookkeepingDatabaseError('commit_entry', 'constraint violation')
expect(err.code).toBe('BOOKKEEPING_DATABASE_ERROR')
expect(err.operation).toBe('commit_entry')
expect(err.cause).toBe('constraint violation')
expect(err.message).toContain('commit_entry')
expect(err.message).toContain('constraint violation')
})
it('BookkeepingDatabaseError handles undefined cause', () => {
const err = new BookkeepingDatabaseError('commit_entry', undefined)
expect(err.message).toContain('commit_entry')
expect(err.message).not.toContain('undefined')
})
})
describe('isAccountsNotInChartError', () => {
it('returns true for AccountsNotInChartError', () => {
expect(isAccountsNotInChartError(new AccountsNotInChartError(['1930']))).toBe(true)
})
it('returns false for other errors', () => {
expect(isAccountsNotInChartError(new Error('plain'))).toBe(false)
expect(isAccountsNotInChartError(new FiscalPeriodNotFoundError())).toBe(false)
expect(isAccountsNotInChartError(null)).toBe(false)
expect(isAccountsNotInChartError(undefined)).toBe(false)
})
})
describe('isBookkeepingError', () => {
it('returns true for all typed bookkeeping errors', () => {
expect(isBookkeepingError(new AccountsNotInChartError(['1930']))).toBe(true)
expect(isBookkeepingError(new JournalEntryNotBalancedError(100, 80))).toBe(true)
expect(isBookkeepingError(new FiscalPeriodNotFoundError())).toBe(true)
expect(isBookkeepingError(new EntryDateOutsideFiscalPeriodError('2024-06-15', 'FY', '2025-01-01', '2025-12-31'))).toBe(true)
expect(isBookkeepingError(new JournalEntryNotFoundError())).toBe(true)
expect(isBookkeepingError(new CannotReverseNonPostedError('draft'))).toBe(true)
expect(isBookkeepingError(new CannotCorrectNonPostedError('draft'))).toBe(true)
expect(isBookkeepingError(new EntryAlreadyReversedError())).toBe(true)
expect(isBookkeepingError(new CurrencyRevaluationAlreadyExistsError())).toBe(true)
expect(isBookkeepingError(new InvalidMappingResultError('1930', '3001'))).toBe(true)
expect(isBookkeepingError(new BookkeepingDatabaseError('commit_entry', 'x'))).toBe(true)
})
it('returns false for plain Error', () => {
expect(isBookkeepingError(new Error('plain'))).toBe(false)
expect(isBookkeepingError(null)).toBe(false)
expect(isBookkeepingError('string')).toBe(false)
})
})
describe('accountsNotInChartResponse', () => {
it('returns 400 with Swedish message and account_numbers', async () => {
const err = new AccountsNotInChartError(['1930', '2641'])
const response = accountsNotInChartResponse(err)
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.message).toContain('1930')
expect(body.error.message).toContain('2641')
expect(body.error.account_numbers).toEqual(['1930', '2641'])
})
})
describe('bookkeepingErrorResponse', () => {
it('returns null for plain Error', () => {
expect(bookkeepingErrorResponse(new Error('plain'))).toBeNull()
expect(bookkeepingErrorResponse(null)).toBeNull()
expect(bookkeepingErrorResponse('string')).toBeNull()
})
it('returns 400 for AccountsNotInChartError (via accountsNotInChartResponse)', async () => {
const response = bookkeepingErrorResponse(new AccountsNotInChartError(['1930']))!
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(body.error.account_numbers).toEqual(['1930'])
})
it('returns 400 for JournalEntryNotBalancedError with totalDebit/totalCredit', async () => {
const response = bookkeepingErrorResponse(new JournalEntryNotBalancedError(100, 80, 'draft'))!
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_BALANCED')
expect(body.error.details).toEqual({ totalDebit: 100, totalCredit: 80, kind: 'draft' })
})
it('returns 404 for FiscalPeriodNotFoundError', async () => {
const response = bookkeepingErrorResponse(new FiscalPeriodNotFoundError())!
expect(response.status).toBe(404)
const body = await response.json()
expect(body.error.code).toBe('FISCAL_PERIOD_NOT_FOUND')
})
it('returns 400 for EntryDateOutsideFiscalPeriodError with period details', async () => {
const response = bookkeepingErrorResponse(
new EntryDateOutsideFiscalPeriodError('2024-06-15', 'FY 2025', '2025-01-01', '2025-12-31')
)!
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('ENTRY_DATE_OUTSIDE_FISCAL_PERIOD')
expect(body.error.details).toEqual({
entryDate: '2024-06-15',
periodName: 'FY 2025',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
})
})
it('returns 404 for JournalEntryNotFoundError', async () => {
const response = bookkeepingErrorResponse(new JournalEntryNotFoundError())!
expect(response.status).toBe(404)
const body = await response.json()
expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_FOUND')
})
it('returns 400 for CannotReverseNonPostedError with currentStatus', async () => {
const response = bookkeepingErrorResponse(new CannotReverseNonPostedError('draft'))!
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('CANNOT_REVERSE_NON_POSTED')
expect(body.error.details).toEqual({ currentStatus: 'draft' })
})
it('returns 400 for CannotCorrectNonPostedError with currentStatus', async () => {
const response = bookkeepingErrorResponse(new CannotCorrectNonPostedError('reversed'))!
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('CANNOT_CORRECT_NON_POSTED')
expect(body.error.details).toEqual({ currentStatus: 'reversed' })
})
it('returns 409 for EntryAlreadyReversedError (concurrent conflict)', async () => {
const response = bookkeepingErrorResponse(new EntryAlreadyReversedError())!
expect(response.status).toBe(409)
const body = await response.json()
expect(body.error.code).toBe('ENTRY_ALREADY_REVERSED')
})
it('returns 409 for CurrencyRevaluationAlreadyExistsError (duplicate)', async () => {
const response = bookkeepingErrorResponse(new CurrencyRevaluationAlreadyExistsError())!
expect(response.status).toBe(409)
const body = await response.json()
expect(body.error.code).toBe('CURRENCY_REVALUATION_ALREADY_EXISTS')
})
it('returns 400 for InvalidMappingResultError with mapping details', async () => {
const response = bookkeepingErrorResponse(new InvalidMappingResultError(null, '3001'))!
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error.code).toBe('INVALID_MAPPING_RESULT')
expect(body.error.details).toEqual({ debitAccount: null, creditAccount: '3001' })
})
it('returns 500 for BookkeepingDatabaseError with operation tag', async () => {
const response = bookkeepingErrorResponse(
new BookkeepingDatabaseError('commit_entry', 'constraint violation')
)!
expect(response.status).toBe(500)
const body = await response.json()
expect(body.error.code).toBe('BOOKKEEPING_DATABASE_ERROR')
expect(body.error.details).toEqual({ operation: 'commit_entry' })
expect(body.error.message).toContain('constraint violation')
})
})
@@ -7,6 +7,7 @@ vi.mock('@/lib/events', () => ({
}))
import { commitEntry, getNextVoucherNumber, createJournalEntry } from '../engine'
import { BookkeepingDatabaseError } from '../errors'
describe('voucher number atomicity', () => {
beforeEach(() => {
@@ -39,7 +40,7 @@ describe('voucher number atomicity', () => {
await expect(
getNextVoucherNumber(supabase as never, 'co-1', 'fp-1', 'A')
).rejects.toThrow('Failed to get next voucher number: connection lost')
).rejects.toThrow(BookkeepingDatabaseError)
})
/**
@@ -59,7 +60,7 @@ describe('voucher number atomicity', () => {
await expect(
commitEntry(supabase as never, 'co-1', 'user-1', 'entry-1')
).rejects.toThrow('Failed to commit journal entry: Journal entry is not balanced')
).rejects.toThrow(BookkeepingDatabaseError)
// The atomic RPC was called — it failed, rolling back both the
// sequence increment and the status update. No burned number.
@@ -231,7 +232,7 @@ describe('createJournalEntry orphan draft cleanup', () => {
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
],
})
).rejects.toThrow('Failed to commit journal entry')
).rejects.toThrow(BookkeepingDatabaseError)
// The orphan draft must have been cancelled with CAS guard (status='draft')
expect(cancelUpdate).toHaveBeenCalledWith({ status: 'cancelled' })
+8 -4
View File
@@ -1,6 +1,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import {
BookkeepingDatabaseError,
CurrencyRevaluationAlreadyExistsError,
} from '@/lib/bookkeeping/errors'
import type {
Currency,
Invoice,
@@ -29,7 +33,7 @@ export async function getOpenForeignCurrencyReceivables(
.not('exchange_rate', 'is', null)
if (error) {
throw new Error(`Failed to fetch foreign currency receivables: ${error.message}`)
throw new BookkeepingDatabaseError('fetch_currency_receivables', error.message)
}
return (data || []) as Invoice[]
@@ -53,7 +57,7 @@ export async function getOpenForeignCurrencyPayables(
.not('exchange_rate', 'is', null)
if (error) {
throw new Error(`Failed to fetch foreign currency payables: ${error.message}`)
throw new BookkeepingDatabaseError('fetch_currency_payables', error.message)
}
return (data || []) as SupplierInvoice[]
@@ -290,11 +294,11 @@ export async function executeCurrencyRevaluation(
.eq('status', 'posted')
if (checkError) {
throw new Error(`Failed to check existing revaluation: ${checkError.message}`)
throw new BookkeepingDatabaseError('check_existing_revaluation', checkError.message)
}
if ((count ?? 0) > 0) {
throw new Error('Currency revaluation already exists for this period')
throw new CurrencyRevaluationAlreadyExistsError()
}
const preview = await previewCurrencyRevaluation(supabase, companyId, closingDate)
+28 -18
View File
@@ -1,6 +1,15 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotReverseNonPostedError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -48,7 +57,7 @@ export async function getNextVoucherNumber(
})
if (error) {
throw new Error(`Failed to get next voucher number: ${error.message}`)
throw new BookkeepingDatabaseError('get_next_voucher_number', error.message)
}
return data as number
@@ -87,7 +96,7 @@ async function resolveAccountIds(
const { data: accounts, error } = await query
if (error) {
throw new Error(`Failed to resolve account IDs: ${error.message}`)
throw new BookkeepingDatabaseError('resolve_account_ids', error.message)
}
const map = new Map<string, string>()
@@ -165,9 +174,7 @@ export async function createDraftEntry(
// Validate balance
const balance = validateBalance(input.lines)
if (!balance.valid) {
throw new Error(
`Journal entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
)
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'draft')
}
// Validate that entry_date falls within the selected fiscal period
@@ -179,12 +186,15 @@ export async function createDraftEntry(
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
throw new FiscalPeriodNotFoundError()
}
if (input.entry_date < period.period_start || input.entry_date > period.period_end) {
throw new Error(
`Entry date ${input.entry_date} is outside fiscal period "${period.name}" (${period.period_start} - ${period.period_end})`
throw new EntryDateOutsideFiscalPeriodError(
input.entry_date,
period.name,
period.period_start,
period.period_end
)
}
@@ -218,7 +228,7 @@ export async function createDraftEntry(
.single()
if (entryError || !entry) {
throw new Error(`Failed to create draft journal entry: ${entryError?.message}`)
throw new BookkeepingDatabaseError('create_draft_entry', entryError?.message)
}
// Insert journal entry lines with dimensions
@@ -230,7 +240,7 @@ export async function createDraftEntry(
if (linesError) {
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', entry.id)
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
throw new BookkeepingDatabaseError('create_entry_lines', linesError.message)
}
// Fetch complete entry with lines
@@ -275,7 +285,7 @@ export async function commitEntry(
})
if (commitError) {
throw new Error(`Failed to commit journal entry: ${commitError.message}`)
throw new BookkeepingDatabaseError('commit_entry', commitError.message)
}
// Fetch complete posted entry with lines
@@ -362,11 +372,11 @@ export async function reverseEntry(
.single()
if (error || !original) {
throw new Error('Journal entry not found')
throw new JournalEntryNotFoundError()
}
if (original.status !== 'posted') {
throw new Error('Can only reverse posted entries')
throw new CannotReverseNonPostedError(original.status)
}
const lines = (original.lines as JournalEntryLine[]) || []
@@ -430,7 +440,7 @@ export async function reverseEntry(
.single()
if (reversalError || !reversalEntry) {
throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
throw new BookkeepingDatabaseError('create_reversal_entry', reversalError?.message)
}
// Insert reversal lines with dimensions
@@ -443,7 +453,7 @@ export async function reverseEntry(
if (linesError) {
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
throw new Error(`Failed to create reversal lines: ${linesError.message}`)
throw new BookkeepingDatabaseError('create_reversal_lines', linesError.message)
}
// Post the reversal entry
@@ -455,7 +465,7 @@ export async function reverseEntry(
if (postError) {
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
throw new Error(`Failed to post reversal entry: ${postError.message}`)
throw new BookkeepingDatabaseError('post_reversal_entry', postError.message)
}
// Mark original as reversed with reversed_by_id link (CAS guard: only if still 'posted')
@@ -474,7 +484,7 @@ export async function reverseEntry(
// reversal as cancelled so it's excluded from reports but remains traceable.
await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', reversalEntry.id)
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
throw new Error('Entry was already reversed by a concurrent operation')
throw new EntryAlreadyReversedError()
}
// If this was a payment entry, sync the linked invoice/supplier-invoice status
+311 -2
View File
@@ -1,6 +1,24 @@
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
@@ -18,10 +36,166 @@ export function isAccountsNotInChartError(err: unknown): err is AccountsNotInCha
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.
* API routes catch the typed error and return this so the frontend can
* open ActivateAccountsDialog and retry the request after activation.
* 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(
@@ -35,3 +209,138 @@ export function accountsNotInChartResponse(err: AccountsNotInChartError) {
{ 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
}
+2 -3
View File
@@ -1,6 +1,7 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { extractNetAmount, extractVatAmount } from './vat-entries'
import { InvalidMappingResultError } from '@/lib/bookkeeping/errors'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
@@ -47,9 +48,7 @@ export async function createTransactionJournalEntry(
mappingResult: MappingResult
): Promise<JournalEntry | null> {
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
`Invalid mapping result: debit_account="${mappingResult.debit_account}", credit_account="${mappingResult.credit_account}". Both must be non-empty.`
)
throw new InvalidMappingResultError(mappingResult.debit_account, mappingResult.credit_account)
}
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date)
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
import { BookkeepingDatabaseError } from '@/lib/bookkeeping/errors'
// ============================================================
// Mock — separate client (no .then) from query builder (thenable)
@@ -172,7 +173,7 @@ describe('correctEntry', () => {
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toThrow('Failed to create corrected entry')
).rejects.toThrow(BookkeepingDatabaseError)
})
it('cancels reversal entry when reversal lines fail', async () => {
@@ -189,7 +190,7 @@ describe('correctEntry', () => {
const supabase = makeClient()
await expect(
correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines)
).rejects.toThrow('Failed to create reversal lines')
).rejects.toThrow(BookkeepingDatabaseError)
})
it('emits journal_entry.corrected event', async () => {
+18 -13
View File
@@ -6,7 +6,14 @@ import type {
JournalEntryLine,
} from '@/types'
import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotCorrectNonPostedError,
EntryAlreadyReversedError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
/**
* Storno Service - 3-step correction flow per Bokföringslagen
@@ -55,9 +62,7 @@ export async function correctEntry(
// Validate the corrected lines are balanced
const balance = validateBalance(correctedLines)
if (!balance.valid) {
throw new Error(
`Corrected entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
)
throw new JournalEntryNotBalancedError(balance.totalDebit, balance.totalCredit, 'correction')
}
// Fetch original entry with lines
@@ -69,11 +74,11 @@ export async function correctEntry(
.single()
if (fetchError || !original) {
throw new Error('Original journal entry not found')
throw new JournalEntryNotFoundError()
}
if (original.status !== 'posted') {
throw new Error('Can only correct posted entries')
throw new CannotCorrectNonPostedError(original.status)
}
const originalLines = (original.lines as JournalEntryLine[]) || []
@@ -104,7 +109,7 @@ export async function correctEntry(
.single()
if (reversalError || !reversalEntry) {
throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
throw new BookkeepingDatabaseError('create_reversal_entry', reversalError?.message)
}
// Insert reversed lines (swap debit and credit)
@@ -130,7 +135,7 @@ export async function correctEntry(
if (reversalLinesError) {
await cancelEntry(supabase, reversalEntry.id)
throw new Error(`Failed to create reversal lines: ${reversalLinesError.message}`)
throw new BookkeepingDatabaseError('create_reversal_lines', reversalLinesError.message)
}
// Post the reversal entry
@@ -141,7 +146,7 @@ export async function correctEntry(
if (postReversalError) {
await cancelEntry(supabase, reversalEntry.id)
throw new Error(`Failed to post reversal entry: ${postReversalError.message}`)
throw new BookkeepingDatabaseError('post_reversal_entry', postReversalError.message)
}
// NOTE: Original entry is NOT marked as 'reversed' here. We defer that
@@ -200,7 +205,7 @@ export async function correctEntry(
.single()
if (correctedError || !newEntry) {
throw new Error(`Failed to create corrected entry: ${correctedError?.message}`)
throw new BookkeepingDatabaseError('create_corrected_entry', correctedError?.message)
}
correctedEntry = newEntry
@@ -230,7 +235,7 @@ export async function correctEntry(
if (correctedLinesError) {
await cancelEntry(supabase, correctedEntry.id)
throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
throw new BookkeepingDatabaseError('create_corrected_lines', correctedLinesError.message)
}
// Post the corrected entry
@@ -241,7 +246,7 @@ export async function correctEntry(
if (postCorrectedError) {
await cancelEntry(supabase, correctedEntry.id)
throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
throw new BookkeepingDatabaseError('post_corrected_entry', postCorrectedError.message)
}
} catch (err) {
// Cancel the reversal entry (posted → cancelled). Original was never
@@ -265,7 +270,7 @@ export async function correctEntry(
// Concurrent reversal beat us — cancel both our entries
await cancelEntry(supabase, reversalEntry.id)
await cancelEntry(supabase, correctedEntry!.id)
throw new Error('Entry was already reversed by a concurrent operation')
throw new EntryAlreadyReversedError()
}
// ===== Step 3: Fetch complete entries =====
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import { getErrorMessage } from '../get-error-message'
describe('getErrorMessage — typed bookkeeping error codes', () => {
it('ACCOUNTS_NOT_IN_CHART → lists accounts to activate', () => {
const msg = getErrorMessage({
error: { code: 'ACCOUNTS_NOT_IN_CHART', message: '...', account_numbers: ['1930', '2641'] },
})
expect(msg).toBe('Följande konton behöver aktiveras: 1930, 2641')
})
it('JOURNAL_ENTRY_NOT_BALANCED with details → rich amount message', () => {
const msg = getErrorMessage({
error: {
code: 'JOURNAL_ENTRY_NOT_BALANCED',
message: 'Journal entry is not balanced: debits (100) != credits (80)',
details: { totalDebit: 100, totalCredit: 80, kind: 'draft' },
},
})
expect(msg).toContain('balanserar inte')
expect(msg).toContain('debet')
expect(msg).toContain('kredit')
expect(msg).toMatch(/100/)
expect(msg).toMatch(/80/)
})
it('JOURNAL_ENTRY_NOT_BALANCED without details → fallback Swedish message', () => {
const msg = getErrorMessage({
error: { code: 'JOURNAL_ENTRY_NOT_BALANCED', message: '...' },
})
expect(msg).toBe('Verifikationen balanserar inte. Kontrollera att debet och kredit är lika stora.')
})
it('FISCAL_PERIOD_NOT_FOUND → Swedish message', () => {
const msg = getErrorMessage({ error: { code: 'FISCAL_PERIOD_NOT_FOUND', message: '...' } })
expect(msg).toBe('Räkenskapsperioden kunde inte hittas.')
})
it('ENTRY_DATE_OUTSIDE_FISCAL_PERIOD → Swedish message', () => {
const msg = getErrorMessage({ error: { code: 'ENTRY_DATE_OUTSIDE_FISCAL_PERIOD', message: '...' } })
expect(msg).toBe('Datumet ligger utanför det valda räkenskapsåret.')
})
it('JOURNAL_ENTRY_NOT_FOUND → Swedish message', () => {
const msg = getErrorMessage({ error: { code: 'JOURNAL_ENTRY_NOT_FOUND', message: '...' } })
expect(msg).toBe('Verifikationen kunde inte hittas.')
})
it('CANNOT_REVERSE_NON_POSTED → Swedish message', () => {
const msg = getErrorMessage({ error: { code: 'CANNOT_REVERSE_NON_POSTED', message: '...' } })
expect(msg).toBe('Endast bokförda verifikationer kan stornas.')
})
it('CANNOT_CORRECT_NON_POSTED → Swedish message', () => {
const msg = getErrorMessage({ error: { code: 'CANNOT_CORRECT_NON_POSTED', message: '...' } })
expect(msg).toBe('Endast bokförda verifikationer kan rättas.')
})
it('ENTRY_ALREADY_REVERSED → Swedish concurrent-conflict message', () => {
const msg = getErrorMessage({ error: { code: 'ENTRY_ALREADY_REVERSED', message: '...' } })
expect(msg).toContain('redan stornats')
expect(msg).toContain('Ladda om sidan')
})
it('CURRENCY_REVALUATION_ALREADY_EXISTS → Swedish message', () => {
const msg = getErrorMessage({ error: { code: 'CURRENCY_REVALUATION_ALREADY_EXISTS', message: '...' } })
expect(msg).toBe('En valutaomvärdering finns redan för denna period.')
})
it('INVALID_MAPPING_RESULT → Swedish message', () => {
const msg = getErrorMessage({ error: { code: 'INVALID_MAPPING_RESULT', message: '...' } })
expect(msg).toBe('Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.')
})
it('BOOKKEEPING_DATABASE_ERROR → generic "kunde inte sparas" when no pattern matches', () => {
const msg = getErrorMessage({
error: {
code: 'BOOKKEEPING_DATABASE_ERROR',
message: 'Database operation "commit_entry" failed: some random constraint',
},
})
expect(msg).toBe('Verifikationen kunde inte sparas. Försök igen.')
})
it('BOOKKEEPING_DATABASE_ERROR falls through to regex pattern for period lock', () => {
// Period-lock trigger errors come through as DB errors — message should still
// match the locked-period pattern and produce the specific Swedish message.
const msg = getErrorMessage({
error: {
code: 'BOOKKEEPING_DATABASE_ERROR',
message: 'Cannot create entry in locked/closed fiscal period',
},
})
expect(msg).toBe('Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.')
})
})
describe('getErrorMessage — existing patterns still work', () => {
it('regex match for "Entry date ... outside fiscal period" on plain string', () => {
const msg = getErrorMessage('Entry date 2024-06-15 is outside fiscal period "FY 2025"')
expect(msg).toBe('Datumet ligger utanför det valda räkenskapsåret.')
})
it('regex match for "locked/closed fiscal period" on plain string', () => {
const msg = getErrorMessage('Cannot create entry in locked/closed fiscal period')
expect(msg).toBe('Perioden är låst. Verifikationen kan inte skapas i en stängd eller låst period.')
})
it('Swedish message passes through unchanged', () => {
const msg = getErrorMessage('Bokföringen är låst t.o.m. 2024-12-31.')
expect(msg).toBe('Bokföringen är låst t.o.m. 2024-12-31.')
})
it('falls through to context fallback when no pattern matches', () => {
const msg = getErrorMessage('Random English error', { context: 'transaction' })
expect(msg).toBe('Kunde inte hantera transaktionen. Försök igen.')
})
it('falls through to HTTP status map', () => {
const msg = getErrorMessage(null, { statusCode: 404 })
expect(msg).toBe('Resursen kunde inte hittas.')
})
it('falls through to generic message', () => {
const msg = getErrorMessage(null)
expect(msg).toBe('Något gick fel. Försök igen.')
})
})
+64 -2
View File
@@ -9,6 +9,8 @@
* 5. Generic fallback
*/
import { formatCurrency } from '@/lib/utils'
type ErrorContext =
| 'invoice'
| 'supplier_invoice'
@@ -194,9 +196,11 @@ export function getErrorMessage(
): string {
const { context, statusCode } = options
// 1. If it's a string, check if it's already Swedish
// 1. If it's a string, check if it's already Swedish or matches a known pattern
if (typeof error === 'string' && error.trim()) {
if (isSwedishUserMessage(error)) return error
const knownError = tryMatchKnownError(error)
if (knownError) return knownError
}
// 2. If it's an object, try various parsing strategies
@@ -205,11 +209,69 @@ export function getErrorMessage(
// Structured application error: { error: { code, message, ... } }
if (typeof obj.error === 'object' && obj.error !== null) {
const structured = obj.error as { code?: unknown; message?: unknown; account_numbers?: unknown }
const structured = obj.error as {
code?: unknown
message?: unknown
account_numbers?: unknown
details?: unknown
}
if (structured.code === 'ACCOUNTS_NOT_IN_CHART' && Array.isArray(structured.account_numbers)) {
const numbers = structured.account_numbers as string[]
return `Följande konton behöver aktiveras: ${numbers.join(', ')}`
}
if (structured.code === 'JOURNAL_ENTRY_NOT_BALANCED') {
const details = structured.details as { totalDebit?: number; totalCredit?: number } | undefined
if (details && typeof details.totalDebit === 'number' && typeof details.totalCredit === 'number') {
return `Verifikationen balanserar inte (${formatCurrency(details.totalDebit)} debet vs ${formatCurrency(details.totalCredit)} kredit).`
}
return 'Verifikationen balanserar inte. Kontrollera att debet och kredit är lika stora.'
}
if (structured.code === 'FISCAL_PERIOD_NOT_FOUND') {
return 'Räkenskapsperioden kunde inte hittas.'
}
if (structured.code === 'ENTRY_DATE_OUTSIDE_FISCAL_PERIOD') {
return 'Datumet ligger utanför det valda räkenskapsåret.'
}
if (structured.code === 'JOURNAL_ENTRY_NOT_FOUND') {
return 'Verifikationen kunde inte hittas.'
}
if (structured.code === 'CANNOT_REVERSE_NON_POSTED') {
return 'Endast bokförda verifikationer kan stornas.'
}
if (structured.code === 'CANNOT_CORRECT_NON_POSTED') {
return 'Endast bokförda verifikationer kan rättas.'
}
if (structured.code === 'ENTRY_ALREADY_REVERSED') {
return 'Verifikationen har redan stornats av en annan användare. Ladda om sidan och försök igen.'
}
if (structured.code === 'CURRENCY_REVALUATION_ALREADY_EXISTS') {
return 'En valutaomvärdering finns redan för denna period.'
}
if (structured.code === 'INVALID_MAPPING_RESULT') {
return 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.'
}
if (structured.code === 'BOOKKEEPING_DATABASE_ERROR') {
// A DB-layer error may carry a user-relevant cause (e.g. period lock
// trigger). Try the known-pattern map before falling back to the
// generic "kunde inte sparas" message.
if (typeof structured.message === 'string') {
const matched = tryMatchKnownError(structured.message)
if (matched) return matched
}
return 'Verifikationen kunde inte sparas. Försök igen.'
}
if (typeof structured.message === 'string' && structured.message.trim()) {
return structured.message
}