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:
@@ -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 }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user