f266c386f3
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
681 lines
22 KiB
TypeScript
681 lines
22 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { DimensionValidationError, MandatoryDimensionMissingError } from './dimension-errors'
|
|
|
|
// ============================================================================
|
|
// Dimension validation errors: the classes live in ./dimension-errors.ts
|
|
// (pure module, no next/server) because dimension-resolver.ts is reachable
|
|
// from client bundles via lib/api/schemas.ts. The two error classes are
|
|
// re-exported here so this file stays the import surface for typed
|
|
// bookkeeping errors; codes, formatters and issue types are imported from
|
|
// ./dimension-errors directly.
|
|
// ============================================================================
|
|
|
|
export { DimensionValidationError, MandatoryDimensionMissingError } from './dimension-errors'
|
|
|
|
// ============================================================================
|
|
// 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_REVERSE_STORNO = 'CANNOT_REVERSE_STORNO' as const
|
|
export const CANNOT_CORRECT_NON_POSTED = 'CANNOT_CORRECT_NON_POSTED' as const
|
|
export const CANNOT_EDIT_NON_DRAFT = 'CANNOT_EDIT_NON_DRAFT' 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
|
|
export const MEANINGLESS_CORRECTION = 'MEANINGLESS_CORRECTION' as const
|
|
export const CORRECTION_CHAIN_TOO_DEEP = 'CORRECTION_CHAIN_TOO_DEEP' as const
|
|
export const NO_OPEN_PERIOD_FOR_DATE = 'NO_OPEN_PERIOD_FOR_DATE' as const
|
|
export const TARGET_PERIOD_CLOSED = 'TARGET_PERIOD_CLOSED' as const
|
|
export const TARGET_PERIOD_LOCKED = 'TARGET_PERIOD_LOCKED' as const
|
|
|
|
// ============================================================================
|
|
// AccountsNotInChartError: kept for back-compat (many existing call sites)
|
|
// ============================================================================
|
|
|
|
export class AccountsNotInChartError extends Error {
|
|
readonly code = ACCOUNTS_NOT_IN_CHART
|
|
readonly accountNumbers: string[]
|
|
|
|
constructor(accountNumbers: string[]) {
|
|
// Numeric-first sort so mixed-length BAS codes (rare but possible) order
|
|
// by value rather than by UTF-16 code units: otherwise ['245', '1930']
|
|
// would sort to ['1930', '245'] under the default string comparator,
|
|
// confusing a user about which accounts to activate in Kontoplan.
|
|
// Non-numeric tokens fall back to a stable string compare so the order
|
|
// is fully deterministic for any input.
|
|
const sorted = [...new Set(accountNumbers)].sort(compareAccountNumbers)
|
|
super(`Accounts not enabled in chart of accounts: ${sorted.join(', ')}`)
|
|
this.name = 'AccountsNotInChartError'
|
|
this.accountNumbers = sorted
|
|
}
|
|
}
|
|
|
|
function compareAccountNumbers(a: string, b: string): number {
|
|
const na = Number(a)
|
|
const nb = Number(b)
|
|
const aIsNum = Number.isFinite(na)
|
|
const bIsNum = Number.isFinite(nb)
|
|
if (aIsNum && bIsNum) {
|
|
if (na !== nb) return na - nb
|
|
// Same numeric value but different string (e.g. "0245" vs "245"):
|
|
// break the tie deterministically by string.
|
|
return a < b ? -1 : a > b ? 1 : 0
|
|
}
|
|
if (aIsNum) return -1
|
|
if (bIsNum) return 1
|
|
return a < b ? -1 : a > b ? 1 : 0
|
|
}
|
|
|
|
export function isAccountsNotInChartError(err: unknown): err is AccountsNotInChartError {
|
|
return err instanceof AccountsNotInChartError
|
|
}
|
|
|
|
// ============================================================================
|
|
// Semantic errors: carry structured data so getErrorMessage can format rich
|
|
// Swedish translations with amounts / period names / status.
|
|
// ============================================================================
|
|
|
|
export class JournalEntryNotBalancedError extends Error {
|
|
readonly code = JOURNAL_ENTRY_NOT_BALANCED
|
|
constructor(
|
|
public readonly totalDebit: number,
|
|
public readonly totalCredit: number,
|
|
public readonly kind: 'draft' | 'correction' = 'draft'
|
|
) {
|
|
super(`Journal entry is not balanced: debits (${totalDebit}) != credits (${totalCredit})`)
|
|
this.name = 'JournalEntryNotBalancedError'
|
|
}
|
|
}
|
|
|
|
export class FiscalPeriodNotFoundError extends Error {
|
|
readonly code = FISCAL_PERIOD_NOT_FOUND
|
|
constructor() {
|
|
super('Fiscal period not found')
|
|
this.name = 'FiscalPeriodNotFoundError'
|
|
}
|
|
}
|
|
|
|
export class EntryDateOutsideFiscalPeriodError extends Error {
|
|
readonly code = ENTRY_DATE_OUTSIDE_FISCAL_PERIOD
|
|
constructor(
|
|
public readonly entryDate: string,
|
|
public readonly periodName: string,
|
|
public readonly periodStart: string,
|
|
public readonly periodEnd: string
|
|
) {
|
|
super(
|
|
`Entry date ${entryDate} is outside fiscal period "${periodName}" (${periodStart} - ${periodEnd})`
|
|
)
|
|
this.name = 'EntryDateOutsideFiscalPeriodError'
|
|
}
|
|
}
|
|
|
|
export class JournalEntryNotFoundError extends Error {
|
|
readonly code = JOURNAL_ENTRY_NOT_FOUND
|
|
constructor() {
|
|
super('Journal entry not found')
|
|
this.name = 'JournalEntryNotFoundError'
|
|
}
|
|
}
|
|
|
|
export class CannotReverseNonPostedError extends Error {
|
|
readonly code = CANNOT_REVERSE_NON_POSTED
|
|
constructor(public readonly currentStatus: string) {
|
|
super('Can only reverse posted entries')
|
|
this.name = 'CannotReverseNonPostedError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Raised when a storno (reversal) is attempted on an entry that is itself a
|
|
* storno. Reversing a storno would produce a storno-of-a-storno and make the
|
|
* original verifikat's cancellation chain ambiguous, violating the
|
|
* traceable-correction requirement of BFL 5 kap 5§. Correction entries are
|
|
* NOT covered: a rättelseverifikation is a regular live verifikat and may be
|
|
* stornoed like any other (its correction_of_id link keeps the chain
|
|
* traceable). The UI hides the "Återför" action for stornos; this is the
|
|
* server-side backstop so a direct API call cannot bypass it.
|
|
*/
|
|
export class CannotReverseStornoError extends Error {
|
|
readonly code = CANNOT_REVERSE_STORNO
|
|
constructor(public readonly sourceType: string) {
|
|
super('Cannot reverse a storno entry')
|
|
this.name = 'CannotReverseStornoError'
|
|
}
|
|
}
|
|
|
|
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'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Raised when an edit is attempted on a committed entry. Only drafts are
|
|
* editable in place; posted/reversed/cancelled entries are immutable per BFL
|
|
* 5 kap. (corrections go through storno). The DB immutability trigger is the
|
|
* backstop: this gives a clean, translatable 409 before we reach it.
|
|
*/
|
|
export class CannotEditNonDraftError extends Error {
|
|
readonly code = CANNOT_EDIT_NON_DRAFT
|
|
constructor(public readonly currentStatus: string) {
|
|
super('Only draft entries can be edited')
|
|
this.name = 'CannotEditNonDraftError'
|
|
}
|
|
}
|
|
|
|
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 type MeaninglessCorrectionReason =
|
|
| 'net_zero_per_account'
|
|
| 'identical_to_original'
|
|
| 'no_date_change'
|
|
|
|
export class MeaninglessCorrectionError extends Error {
|
|
readonly code = MEANINGLESS_CORRECTION
|
|
constructor(public readonly reason: MeaninglessCorrectionReason) {
|
|
super(
|
|
reason === 'net_zero_per_account'
|
|
? 'Correction lines net to zero on every account: no economic event represented (BFL 5 kap. 5 §).'
|
|
: reason === 'no_date_change'
|
|
? 'New date equals the current date: nothing to move.'
|
|
: 'Correction lines are identical to the original entry: nothing to correct.'
|
|
)
|
|
this.name = 'MeaninglessCorrectionError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Raised when a correction/reversal targets an entry that already sits
|
|
* CORRECTION_CHAIN_GUARD_DEPTH or more links deep in a rättelse chain
|
|
* (correction_of_id/reverses_id walked backwards). Stacking yet another
|
|
* storno+rättelse on top buries the journal in noise vouchers; the sanctioned
|
|
* fix is ONE correction expressing the net effect of the whole chain. The
|
|
* guard is advisory: callers bypass it with an explicit allowDeepChain flag
|
|
* (allow_deep_chain on the API/MCP surfaces), so it never dead-ends a
|
|
* legitimate deep fix.
|
|
*/
|
|
export class CorrectionChainTooDeepError extends Error {
|
|
readonly code = CORRECTION_CHAIN_TOO_DEEP
|
|
constructor(
|
|
public readonly depth: number,
|
|
public readonly chainRootVoucher: string | null
|
|
) {
|
|
super(
|
|
`Correction chain is already ${depth} levels deep` +
|
|
(chainRootVoucher ? ` (chain root: ${chainRootVoucher})` : '') +
|
|
'. Compute the net effect of the whole chain and book ONE correction instead, ' +
|
|
'or pass allow_deep_chain=true to override.'
|
|
)
|
|
this.name = 'CorrectionChainTooDeepError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Raised when a verifikation is moved (recordate) to a date that no fiscal
|
|
* period covers. We do not auto-create periods on a correction.
|
|
*/
|
|
export class NoOpenPeriodForDateError extends Error {
|
|
readonly code = NO_OPEN_PERIOD_FOR_DATE
|
|
constructor(public readonly date: string) {
|
|
super(`No fiscal period covers ${date}`)
|
|
this.name = 'NoOpenPeriodForDateError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Raised when the target date of a recordate falls in a closed fiscal year
|
|
* (bokslut). A closed year cannot be reopened: the correction must be booked
|
|
* in the current open period instead.
|
|
*/
|
|
export class TargetPeriodClosedError extends Error {
|
|
readonly code = TARGET_PERIOD_CLOSED
|
|
constructor(public readonly date: string) {
|
|
super(`The fiscal period covering ${date} is closed`)
|
|
this.name = 'TargetPeriodClosedError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Raised when the target date of a recordate falls in a locked period or is
|
|
* covered by the company-wide bookkeeping lock date. Carries the lock date so
|
|
* the UI can offer an unlock affordance.
|
|
*/
|
|
export class TargetPeriodLockedError extends Error {
|
|
readonly code = TARGET_PERIOD_LOCKED
|
|
constructor(
|
|
public readonly date: string,
|
|
public readonly lockDate: string | null
|
|
) {
|
|
super(
|
|
`The fiscal period covering ${date} is locked${lockDate ? ` (lock date ${lockDate})` : ''}`
|
|
)
|
|
this.name = 'TargetPeriodLockedError'
|
|
}
|
|
}
|
|
|
|
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'
|
|
| 'commit_asset_disposal'
|
|
| 'fetch_asset_disposal_entry'
|
|
| 'create_reversal_entry'
|
|
| 'create_reversal_lines'
|
|
| 'post_reversal_entry'
|
|
| 'replace_opening_balance'
|
|
| 'create_corrected_entry'
|
|
| 'create_corrected_lines'
|
|
| 'post_corrected_entry'
|
|
| 'fetch_currency_receivables'
|
|
| 'fetch_currency_payables'
|
|
| 'check_existing_revaluation'
|
|
| 'resolve_settlement_account'
|
|
|
|
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'
|
|
}
|
|
}
|
|
|
|
export interface UnusedVoucherAllocation {
|
|
fiscalPeriodId: string
|
|
voucherSeries: string
|
|
voucherNumber: number
|
|
}
|
|
|
|
const UNUSED_VOUCHER_ALLOCATION = Symbol('unused-voucher-allocation')
|
|
|
|
/**
|
|
* Preserve the exact durable sequence allocation when an engine operation
|
|
* fails before any journal-entry row uses the number. The original error type
|
|
* is retained so existing API mappings remain unchanged.
|
|
*/
|
|
export function withUnusedVoucherAllocation<T>(
|
|
error: T,
|
|
allocation: UnusedVoucherAllocation,
|
|
): T {
|
|
if (error instanceof Error) {
|
|
Object.defineProperty(error, UNUSED_VOUCHER_ALLOCATION, {
|
|
value: allocation,
|
|
enumerable: false,
|
|
})
|
|
}
|
|
return error
|
|
}
|
|
|
|
export function getUnusedVoucherAllocation(error: unknown): UnusedVoucherAllocation | null {
|
|
if (!(error instanceof Error)) return null
|
|
return (
|
|
error as Error & { [UNUSED_VOUCHER_ALLOCATION]?: UnusedVoucherAllocation }
|
|
)[UNUSED_VOUCHER_ALLOCATION] ?? null
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 CannotReverseStornoError ||
|
|
err instanceof CannotCorrectNonPostedError ||
|
|
err instanceof CannotEditNonDraftError ||
|
|
err instanceof EntryAlreadyReversedError ||
|
|
err instanceof CurrencyRevaluationAlreadyExistsError ||
|
|
err instanceof InvalidMappingResultError ||
|
|
err instanceof BookkeepingDatabaseError ||
|
|
err instanceof MeaninglessCorrectionError ||
|
|
err instanceof CorrectionChainTooDeepError ||
|
|
err instanceof NoOpenPeriodForDateError ||
|
|
err instanceof TargetPeriodClosedError ||
|
|
err instanceof TargetPeriodLockedError ||
|
|
err instanceof DimensionValidationError ||
|
|
err instanceof MandatoryDimensionMissingError
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Response helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Build a structured 400 response for AccountsNotInChartError.
|
|
* Kept for back-compat with existing callers; new code should prefer
|
|
* bookkeepingErrorResponse() which covers all typed bookkeeping errors.
|
|
*/
|
|
export function accountsNotInChartResponse(err: AccountsNotInChartError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: `Följande konton behöver aktiveras: ${err.accountNumbers.join(', ')}`,
|
|
// Dual-emit: top-level for legacy frontend callers, nested under
|
|
// `details` to match the v1 envelope shape so a single client (MCP
|
|
// or external) can read `error.details.account_numbers` regardless
|
|
// of which categorize endpoint it hit.
|
|
account_numbers: err.accountNumbers,
|
|
details: { account_numbers: err.accountNumbers },
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Build a structured JSON response for any typed bookkeeping error.
|
|
* Returns null if `err` is not a recognized bookkeeping error so callers can
|
|
* fall through to their existing generic handling.
|
|
*
|
|
* Response shape: { error: { code, message, details? } }
|
|
* HTTP status: 404 for *_NOT_FOUND, 409 for concurrent/duplicate conflicts,
|
|
* 500 for BOOKKEEPING_DATABASE_ERROR, 400 otherwise.
|
|
*/
|
|
export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
|
|
if (err instanceof AccountsNotInChartError) {
|
|
return accountsNotInChartResponse(err)
|
|
}
|
|
|
|
if (err instanceof JournalEntryNotBalancedError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: {
|
|
totalDebit: err.totalDebit,
|
|
totalCredit: err.totalCredit,
|
|
kind: err.kind,
|
|
},
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof FiscalPeriodNotFoundError) {
|
|
return NextResponse.json(
|
|
{ error: { code: err.code, message: err.message } },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof EntryDateOutsideFiscalPeriodError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: {
|
|
entryDate: err.entryDate,
|
|
periodName: err.periodName,
|
|
periodStart: err.periodStart,
|
|
periodEnd: err.periodEnd,
|
|
},
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof JournalEntryNotFoundError) {
|
|
return NextResponse.json(
|
|
{ error: { code: err.code, message: err.message } },
|
|
{ status: 404 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof CannotReverseNonPostedError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { currentStatus: err.currentStatus },
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof CannotReverseStornoError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { sourceType: err.sourceType },
|
|
},
|
|
},
|
|
{ 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 CannotEditNonDraftError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { currentStatus: err.currentStatus },
|
|
},
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
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 MeaninglessCorrectionError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { reason: err.reason },
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof CorrectionChainTooDeepError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { depth: err.depth, chainRootVoucher: err.chainRootVoucher },
|
|
},
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof NoOpenPeriodForDateError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { date: err.date },
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof TargetPeriodClosedError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { date: err.date },
|
|
},
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof TargetPeriodLockedError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { date: err.date, lockDate: err.lockDate },
|
|
},
|
|
},
|
|
{ status: 409 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof DimensionValidationError) {
|
|
// err.message is already the user-facing Swedish sentence(s) naming the
|
|
// offending code(s); details.issues carries the machine-readable list.
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { issues: err.issues },
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof MandatoryDimensionMissingError) {
|
|
// Policy rejection at commit (dimensions PR10): the message names every
|
|
// account + required dimension so a user or agent self-corrects in one
|
|
// pass; details.violations is the machine-readable list.
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { violations: err.violations },
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
if (err instanceof BookkeepingDatabaseError) {
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: err.code,
|
|
message: err.message,
|
|
details: { operation: err.operation },
|
|
},
|
|
},
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|