diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 40dd9a4d..e5ac5dde 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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' }) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts index 4a373066..4234a1fe 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/currency-revaluation/route.ts @@ -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 } diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts index 1af2cd70..7b065b0f 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -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 } diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts index 9502ee99..df3aafc3 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts @@ -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 } diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index e7337ffb..c1c38ce0 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -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 } diff --git a/app/api/import/opening-balance/execute/route.ts b/app/api/import/opening-balance/execute/route.ts index 3519fc48..5f7b07bb 100644 --- a/app/api/import/opening-balance/execute/route.ts +++ b/app/api/import/opening-balance/execute/route.ts @@ -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' }, diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 894309a1..2aee05bf 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -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' }, diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index e335452a..56025e75 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -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 } diff --git a/app/api/salary/runs/[id]/correct/route.ts b/app/api/salary/runs/[id]/correct/route.ts index 82d50e81..194b0f7d 100644 --- a/app/api/salary/runs/[id]/correct/route.ts +++ b/app/api/salary/runs/[id]/correct/route.ts @@ -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 }) } } diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index 44388edd..2fa223a8 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -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.' }, diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index bd2e3a53..4ff471c5 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -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' }, diff --git a/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts index 70d3f611..5606b739 100644 --- a/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts @@ -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 }, diff --git a/app/api/supplier-invoices/[id]/uncredit/route.ts b/app/api/supplier-invoices/[id]/uncredit/route.ts index 38832005..2b3c2ee8 100644 --- a/app/api/supplier-invoices/[id]/uncredit/route.ts +++ b/app/api/supplier-invoices/[id]/uncredit/route.ts @@ -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( diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index dd9689ea..1418be5c 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -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.' }, diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index a76da58f..77bb09b2 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -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 } diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 7ada5465..d87d7740 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -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 } diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 64815732..9a95f681 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -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 } diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index b51aae27..7bf99e55 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -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) } diff --git a/app/api/transactions/[id]/uncategorize/route.ts b/app/api/transactions/[id]/uncategorize/route.ts index 14f19063..caac6125 100644 --- a/app/api/transactions/[id]/uncategorize/route.ts +++ b/app/api/transactions/[id]/uncategorize/route.ts @@ -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 }) } diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index ff4356c5..62a25334 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -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 { diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index def7f0c7..aeb009e5 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -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', }) } diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts index bf1882b2..6e99c230 100644 --- a/lib/bookkeeping/__tests__/engine.test.ts +++ b/lib/bookkeeping/__tests__/engine.test.ts @@ -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' }) diff --git a/lib/bookkeeping/__tests__/errors.test.ts b/lib/bookkeeping/__tests__/errors.test.ts new file mode 100644 index 00000000..43c31572 --- /dev/null +++ b/lib/bookkeeping/__tests__/errors.test.ts @@ -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') + }) +}) diff --git a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts index 1283477b..561904f6 100644 --- a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts +++ b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts @@ -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' }) diff --git a/lib/bookkeeping/currency-revaluation.ts b/lib/bookkeeping/currency-revaluation.ts index cfc140dd..dc3bb51b 100644 --- a/lib/bookkeeping/currency-revaluation.ts +++ b/lib/bookkeeping/currency-revaluation.ts @@ -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) diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index b45d7e65..4151df85 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -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() @@ -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 diff --git a/lib/bookkeeping/errors.ts b/lib/bookkeeping/errors.ts index e9329667..c7347352 100644 --- a/lib/bookkeeping/errors.ts +++ b/lib/bookkeeping/errors.ts @@ -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 : " +// 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 +} diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts index b9a4e87d..6f80a728 100644 --- a/lib/bookkeeping/transaction-entries.ts +++ b/lib/bookkeeping/transaction-entries.ts @@ -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 { 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) diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts index f6f6ad9d..e1e5add0 100644 --- a/lib/core/bookkeeping/__tests__/storno-service.test.ts +++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts @@ -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 () => { diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index 264e1b00..5b5ca2ce 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -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 ===== diff --git a/lib/errors/__tests__/get-error-message.test.ts b/lib/errors/__tests__/get-error-message.test.ts new file mode 100644 index 00000000..22015f4b --- /dev/null +++ b/lib/errors/__tests__/get-error-message.test.ts @@ -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.') + }) +}) diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index 3d335160..a79c0663 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -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 }