cb3ef45f14
* feat(assets): atomic asset disposal workflow (avyttring, utrangering, verksamhetsoverlatelse) Disposal books depreciation to the disposal date, clears cost and accumulated depreciation, books gain (3973) or loss (7973), applies output VAT on third-party sales, honors the ML 5 kap. 38 § verksamhetsoverlatelse exemption, and recalculates ML 15 kap. jamkning server-side from tax years and original input VAT. The voucher, the disposal-date depreciation schedule and the immutable register state commit in one dedicated commit_asset_disposal RPC transaction that delegates voucher numbering to commit_journal_entry. Fixes #325 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(assets): harden disposal per review and pg-real findings - commit_asset_disposal now uses the NULL-safe caller_is_company_member() guard (tenant-guard ratchet) and passes the allowed 'user_accept' commit_method instead of the unlisted 'asset_disposal' value - disposal metadata invariants validated in the RPC (non-negative proceeds/VAT, VAT requires a treatment, VAT <= gross, scrap carries no proceeds) since the RPC is independently callable - new FK and CHECK constraints added NOT VALID + VALIDATE CONSTRAINT so the migration never blocks writes on the hot journal_entries table - disposeAsset paginates fiscal periods and depreciation schedules with fetchAllRows; jamkning_remaining_years keeps a valid 0 (?? not ||) - engine imports shared AssetDisposalType/AssetJamkningDirection/ VatTreatment unions; post-commit reload retries once and logs before surfacing, so a transient read cannot masquerade as a failed disposal - dispose page parses Swedish-formatted amounts (125 000,50) and blocks submission on unparseable proceeds - assets pg tests write disposal attributes in the disposal transition itself and gain a regression test that the register is frozen after Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
620 lines
20 KiB
TypeScript
620 lines
20 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { DimensionValidationError, MandatoryDimensionMissingError } from './dimension-errors'
|
|
|
|
// ============================================================================
|
|
// Dimension validation error: class lives in ./dimension-errors.ts (pure
|
|
// module, no next/server) because dimension-resolver.ts is reachable from
|
|
// client bundles via lib/api/schemas.ts. Re-exported here so this file stays
|
|
// the single import surface for all typed bookkeeping errors.
|
|
// ============================================================================
|
|
|
|
export {
|
|
DIMENSION_VALIDATION_FAILED,
|
|
DimensionValidationError,
|
|
formatDimensionValidationIssue,
|
|
formatDimensionValidationIssues,
|
|
isDimensionValidationError,
|
|
MANDATORY_DIMENSION_MISSING,
|
|
MandatoryDimensionMissingError,
|
|
formatMandatoryDimensionViolation,
|
|
isMandatoryDimensionMissingError,
|
|
} from './dimension-errors'
|
|
export type {
|
|
DimensionValidationIssue,
|
|
DimensionValidationReason,
|
|
MandatoryDimensionViolation,
|
|
} 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 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 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'
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 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 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
|
|
}
|