Files
accounted/lib/errors/get-structured-error.ts
T
Mattsson 60e33c4b51 Fix/cus fee 28 (#820)
* feat(invoices): add Plusgiro input to bank details settings

Plusgiro was already persisted, validated by the API schema, rendered on
the invoice PDF and toggleable via "Visa plusgiro" — but the settings UI
had no field to enter the number, so plusgiro-only users could not fill
it in. Add the input next to Bankgiro with Luhn validation and hyphen
formatting, include it in the save payload (normalised on save so raw
digits still match the dashed schema format), and add sv/en strings.

Adds validatePlusgiroNumber/formatPlusgiroNumber helpers + tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(invoices): respect non-VAT-registered seller in PDF preview + portal tooltips

Two user-reported bugs:

- PDF preview (/api/invoices/preview-pdf) ignored company.vat_registered and
  fell back to the customer-driven 25% rate, so a non-momsregistrerad seller
  saw VAT in the review step even though the created invoice books none. Mirror
  the server-side write gate (build-invoice-write.ts): force 0% when
  vat_registered is false (delivery notes excepted).

- InfoTooltip rendered TooltipContent without a Portal, so tooltips were
  clipped by the scrollable DialogContent (overflow-y-auto) in the send-invoice
  journal-entry review. Wrap in TooltipPrimitive.Portal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(transactions): book library mall from its literal lines, not a lossy fallback

Booking a bank transaction with a user-created booking-template (mall) via the
convertible "QuickReview" fast path reduced the template to a single category +
one account_override, silently discarding the chosen debit/credit. A
kundinbetalning mall (D 1930 / K 1510) booked as a generic cost (D 6991 / K 1930),
or with a VAT line as D 1930 / K 1930 / K 2611 — and the result flipped with the
direction inferred from the business/settlement line tags, so visually-identical
templates produced different verifikationer.

Route every library template through the journal-entry editor (applyTemplate ->
/book), which posts the literal lines, regardless of convertibility. Add
regression tests locking the contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): make the booking-time duplicate guard bypassable

TRANSACTION_BOOK_POSSIBLE_DUPLICATE told users they could "book anyway" but
the UI dead-ended on a toast with no way to do so. Add a shared
DuplicateBookingDialog that surfaces the already-booked sibling and lets the
user review it or book anyway (force bound to the reviewed candidate, which
the server re-detects so a stale id cannot wave the guard away).

- Wire the dialog into the /transactions categorize flow and the manual
  booking dialog (JournalEntryForm -> /api/transactions/[id]/book)
- Bind the override to expected_duplicate_transaction_id OR
  expected_duplicate_journal_entry_id so ledger-only vouchers (paid invoice,
  salary run) can be confirmed too
- Extend the guard to the pending-operations commit path and the MCP server
- Tests for book/categorize routes, detection, and the commit guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): log duplicate-guard bypass to behandlingshistorik in the agent commit path

The web /book and /categorize routes append a durable
BankTransactionDuplicateDismissed event when a user books over a detected
possible double-booking. The agent commit path (commitCategorizeTransaction,
commitMarkInvoicePaid) skipped the guard silently on allow_duplicate=true,
leaving no behandlingshistorik — an auditor could not reconstruct why the
duplicate was allowed (BFNAR 2013:2 kap 8).

When allow_duplicate=true, re-detect the candidate and append the dismissal
event (BankTransactionDuplicateDismissed for the bank-line path,
InvoiceDuplicatePaymentDismissed for mark-paid). Best-effort — a logging
failure never blocks a legitimate booking. Payloads stay PII-safe (ids,
amounts, dates only — no customer or merchant name).

Also fix the misleading DuplicateBookingDialog JSDoc: the retry binds
expected_duplicate_journal_entry_id, not candidate.transaction_id, so the
systemdokumentation matches the actual control (BFL 7 kap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp-server): stub booking-duplicate guard in receipt-matcher categorize tests

The gnubok_categorize_transaction tool runs the booking-time duplicate guard
before staging; its detection queries consumed the queued supabase mock
results, so the staging assertions saw a thrown duplicate error instead of a
staged op. Mock detectBookingDuplicate to "no duplicate" since these tests
don't exercise that path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(transactions): use roundOre for duplicate-guard öre rounding

Replace naive Math.round(x*100)/100 with roundOre() from @/lib/money in the
booking-time duplicate guard (detection lib, commit executor, MCP categorize
tool), satisfying the no-new-antipatterns ratchet guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sie-export): paginate journal entries and lines to prevent truncation

* fix(bookkeeping): keep the Verifikat/Utkast toggle reachable on an empty list

The journal entry list early-returned a pristine empty card whenever the visible list was empty and no filter was active, returning before the Verifikat/Utkast toggle rendered. This stranded users with only drafts (no posted entries) and users who emptied the drafts list, who then had to use the main menu to get back to posted entries.

Narrow the early return to a genuinely empty ledger (committed view, no drafts, no filters); make the in-list empty placeholder context-aware (no drafts / no filter matches / no posted entries yet); resolve the draft count before clearing loading on an empty committed list to avoid a toggle flicker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(enable-banking): persist psu_type and reuse it on reconnect

Reconnecting a bank connection re-derived psu_type from the company entity_type every time (aktiebolag -> 'business'), silently overriding the type the user actually authorized with. A connection that only signs as 'personal' — common for AB owners who use a personal Mobile BankID, notably at Handelsbanken — flipped back to 'business' on every consent renewal and failed at the bank's signing step.

- Add nullable bank_connections.psu_type column (idempotent migration)
- Persist psu_type on connect; on reconnect reuse the stored value (explicit client override still wins)
- Let users switch account type (Företag/Privat) from the reconnect button
- Tests for persistence, reuse, and override

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): set maxDuration=300 on bank-file execute to prevent timeout

A full-year bank file (300+ rows) runs a sequential per-row ingest that takes ~85s of server time. The execute route set no maxDuration, so it inherited the platform default and was killed mid-run — the import "spins then aborts" for the user. Match the SIE import route and give it a 5-minute budget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(transactions): add assistant entry point on transaction rows

The agent ("Lena") could only be reached from Dokumentinkorgen, and only once an underlag was matched to a transaction. Transaktioner is the most common starting point for booking, so users could not start a booking with the assistant from there at all.

Add a per-row "Fråga [namn]" button on unbooked transaction rows that opens the existing transaction.categorization intent with the row's transaction_id. The intent already reads any linked underlag, so it works whether or not a receipt is attached. No new logic — only the missing entry point.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): enable Swish payment QR on invoices

Flip SHOW_SWISH_ON_INVOICE on so the Swish row and payment QR render on the invoice PDF, and make the "Visa Swish" settings toggle live (it was hardcoded disabled). The preview-pdf route now builds the QR too, so it shows in forhandsvisning. Position the QR in the top-right of the payment box. No Swish API integration -- the QR is generated offline and prefills the customer Swish app; reconciliation stays via bank matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): scope verifikat list to current year, add storno action, clarify correction preview

Three UI fixes from user feedback; no engine logic changed.

- List defaults to the current räkenskapsår instead of all years. Voucher
  numbers run per fiscal year (one A42/year), so showing every year at once
  made them look like duplicates. New resolveCurrentPeriodId helper.
- Add 'Återför (storno)' action on the entry detail page and list row, wiring
  the existing reverseEntry — a pure reversal (BFL 5 kap 5§) with no
  replacement, distinct from 'Rätta'.
- Correction 'Effekt per konto' preview now labels a removed account 'tas bort'
  (vs a bare dash) and warns when the proposal is unbalanced; dialog explains
  the rows are the full new verifikat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bank_connections): add psu_type column to persist chosen authorization type

* feat(errors): add CannotReverseStornoError for handling reversal of storno or correction entries

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:34:51 +02:00

430 lines
14 KiB
TypeScript

/**
* Structured error shape designed for agents (MCP, automation) that need to
* dispatch on error programmatically rather than read the Swedish prose.
*
* Key design decisions:
* - code is machine-readable and stable; agents pattern-match on it
* - message_sv is the existing UI string from getErrorMessage()
* - message_en gives the agent a translation it can act on without parsing
* Swedish tokens
* - remediation, when present, points the agent at a tool/args/resource
* that fixes the problem. Optional — only set when there's a clear
* mechanical next step
*
* Both MCP and REST consume this. errorResponse() below produces the standard
* REST envelope so a single registry covers every entry point.
*/
import { NextResponse } from 'next/server'
import { ZodError } from 'zod'
import { getErrorMessage } from './get-error-message'
import {
getErrorEntry,
type StructuredErrorEntry,
type StructuredErrorRemediation,
} from './structured-errors'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotCorrectNonPostedError,
CannotReverseNonPostedError,
CannotReverseStornoError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
InvalidMappingResultError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
CurrencyRevaluationAlreadyExistsError,
MeaninglessCorrectionError,
NoOpenPeriodForDateError,
TargetPeriodClosedError,
TargetPeriodLockedError,
isBookkeepingError,
} from '../bookkeeping/errors'
export type { StructuredErrorRemediation }
export interface StructuredError {
code: string
message_sv: string
message_en: string
remediation?: StructuredErrorRemediation
/**
* Present (true) only when the failure is transient. Agents may retry the
* same request after a short backoff. Absent or false means the request
* will fail the same way until inputs or system state change.
*/
retryable?: boolean
}
interface StructuredErrorOptions {
/**
* Optional: scope the agent attempted to use, for INSUFFICIENT_SCOPE remediation.
*/
attemptedScope?: string
/**
* Optional: tool name being called, used in fallback remediation hints.
*/
toolName?: string
}
/**
* Pull a stable code out of various error shapes.
*/
function extractCode(error: unknown): string | null {
if (typeof error !== 'object' || error === null) return null
const obj = error as Record<string, unknown>
// Typed bookkeeping error: { code: 'JOURNAL_ENTRY_NOT_BALANCED', ... }
if (typeof obj.code === 'string' && /^[A-Z_]+$/.test(obj.code)) {
return obj.code
}
// Wrapped error: { error: { code: '...' } }
if (typeof obj.error === 'object' && obj.error !== null) {
const inner = obj.error as Record<string, unknown>
if (typeof inner.code === 'string' && /^[A-Z_]+$/.test(inner.code)) {
return inner.code
}
}
return null
}
/**
* Heuristically infer a code from the message text when nothing structured
* is available. Keeps known-error patterns programmatically dispatchable.
*/
function inferCode(message: string): string | null {
if (/Period must be locked before closing/i.test(message)) return 'PERIOD_NOT_LOCKED'
if (/Year-end closing must be executed/i.test(message)) return 'YEAR_END_NOT_RUN'
if (/Kan inte låsa period:.*affärstransaktion/i.test(message)) return 'PERIOD_HAS_UNBOOKED_TRANSACTIONS'
if (/Insufficient scope/i.test(message)) return 'INSUFFICIENT_SCOPE'
if (/already has a journal entry/i.test(message)) return 'TRANSACTION_ALREADY_CATEGORIZED'
if (/already been sent/i.test(message) || /already sent/i.test(message)) return 'INVOICE_ALREADY_SENT'
if (/locked\/closed fiscal period/i.test(message)) return 'PERIOD_LOCKED'
if (/Bokföringen är låst/i.test(message)) return 'PERIOD_LOCKED'
if (/Transaction not found/i.test(message)) return 'NOT_FOUND'
if (/Invoice not found/i.test(message)) return 'NOT_FOUND'
return null
}
function extractEnglishMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message
if (typeof error === 'object' && error !== null) {
const obj = error as Record<string, unknown>
if (typeof obj.error === 'string') return obj.error
if (typeof obj.message === 'string') return obj.message
if (typeof obj.error === 'object' && obj.error !== null) {
const inner = obj.error as Record<string, unknown>
if (typeof inner.message === 'string') return inner.message
}
}
return 'Unknown error'
}
/**
* Build a StructuredError for an arbitrary thrown value.
*
* Always returns a valid StructuredError; never throws.
*/
export function getStructuredError(
error: unknown,
options: StructuredErrorOptions = {}
): StructuredError {
const message_en = extractEnglishMessage(error)
const message_sv = getErrorMessage(error)
const code = extractCode(error) ?? inferCode(message_en) ?? 'UNKNOWN_ERROR'
const entry = getErrorEntry(code)
let remediation = entry?.remediation
// Specialize INSUFFICIENT_SCOPE with the actual scope name when known.
if (code === 'INSUFFICIENT_SCOPE' && options.attemptedScope && remediation) {
remediation = {
...remediation,
description: `The current API key does not have the "${options.attemptedScope}" scope. Mint a new key with that scope or add it to the existing key in API settings.`,
}
}
return {
code,
message_sv,
message_en,
...(remediation ? { remediation } : {}),
...(entry?.retryable ? { retryable: true } : {}),
}
}
// ────────────────────────────────────────────────────────────────────
// REST error envelope
// ────────────────────────────────────────────────────────────────────
export interface ErrorEnvelope {
error: {
code: string
message: string
message_en?: string
remediation?: StructuredErrorRemediation
requestId?: string
details?: unknown
}
}
interface ErrorResponseContext {
requestId?: string
/** Additional details to attach to the response for the user/agent. */
details?: unknown
/** When known, override the http status from the registry entry. */
status?: number
/**
* Override the registry messages when the route computes a dynamic message
* (e.g. interpolating a rolling year range). Provide both or neither so the
* sv/en pair never drifts apart.
*/
messageSv?: string
messageEn?: string
}
interface MinimalLogger {
error: (msg: string, ...args: unknown[]) => void
}
function entryFor(code: string): StructuredErrorEntry {
return (
getErrorEntry(code) ??
getErrorEntry('INTERNAL_ERROR') ?? {
httpStatus: 500,
message_sv: 'Något gick fel. Försök igen.',
message_en: 'Internal server error.',
}
)
}
function postgresCodeToStructured(code: string): string | null {
switch (code) {
case '23505':
case '23503':
case '23514':
case '22P02':
case '22003':
return 'VALIDATION_ERROR'
case '23502':
return 'VALIDATION_ERROR'
case '42501':
return 'FORBIDDEN'
case '42P01':
return 'NOT_FOUND'
case '40001':
case '40P01':
return 'CONFLICT'
default:
return null
}
}
function isZodError(err: unknown): err is ZodError {
return err instanceof ZodError || (err instanceof Error && err.name === 'ZodError')
}
function isPostgresError(err: unknown): err is { code: string; message: string } {
return (
typeof err === 'object' &&
err !== null &&
typeof (err as { code?: unknown }).code === 'string' &&
/^[0-9A-Z]{5}$/.test((err as { code: string }).code)
)
}
/**
* Build the canonical REST error envelope for any thrown value.
*
* Order of dispatch:
* 1. typed BookkeepingError → reuses bookkeepingErrorResponse()
* 2. ZodError → VALIDATION_ERROR with field-level details
* 3. Postgres error code → mapped to a structured code
* 4. Error with `code` field present in registry → use that
* 5. Anything else → INTERNAL_ERROR
*
* Always logs the underlying error (no silent error returns). The caller
* must pass a logger so the request id propagates to the log line.
*/
export function errorResponse(
err: unknown,
log: MinimalLogger,
ctx: ErrorResponseContext = {},
): NextResponse {
// 1. Bookkeeping domain errors — route through the registry, preserving
// the structured details each typed error class carries.
if (isBookkeepingError(err)) {
const { code, details } = extractBookkeepingDetails(err)
log.error(code, err as Error, { requestId: ctx.requestId })
const entry = entryFor(code)
return buildResponse(code, entry, ctx.requestId, details ?? ctx.details)
}
// 2. Zod validation errors
if (isZodError(err)) {
const issues = (err as ZodError).issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
code: i.code,
}))
log.error('validation failed', err as Error, {
requestId: ctx.requestId,
issueCount: issues.length,
})
const entry = entryFor('VALIDATION_ERROR')
const details = mergeDetails({ issues }, ctx.details)
return buildResponse('VALIDATION_ERROR', entry, ctx.requestId, details)
}
// 3. Postgres errors
if (isPostgresError(err)) {
const mapped = postgresCodeToStructured(err.code)
log.error('database error', err as unknown as Error, {
requestId: ctx.requestId,
pgCode: err.code,
})
if (mapped) {
const entry = entryFor(mapped)
const details = mergeDetails({ pgCode: err.code }, ctx.details)
return buildResponse(mapped, entry, ctx.requestId, details)
}
}
// 4. Errors with a known structured code on them
const code = extractCode(err)
if (code && getErrorEntry(code)) {
const entry = entryFor(code)
log.error(`${code}`, err instanceof Error ? err : new Error(String(err)), { requestId: ctx.requestId })
const status = ctx.status ?? entry.httpStatus
return buildResponse(code, { ...entry, httpStatus: status }, ctx.requestId, ctx.details)
}
// 5. Fallback — log the actual error so we can still debug
log.error('unhandled error', err instanceof Error ? err : new Error(String(err)), {
requestId: ctx.requestId,
})
const fallback = entryFor('INTERNAL_ERROR')
return buildResponse('INTERNAL_ERROR', fallback, ctx.requestId, ctx.details)
}
function mergeDetails(
base: Record<string, unknown>,
extra: unknown,
): Record<string, unknown> {
if (extra && typeof extra === 'object' && !Array.isArray(extra)) {
return { ...base, ...(extra as Record<string, unknown>) }
}
return base
}
function extractBookkeepingDetails(err: unknown): { code: string; details?: unknown } {
if (err instanceof AccountsNotInChartError) {
return { code: err.code, details: { account_numbers: err.accountNumbers } }
}
if (err instanceof JournalEntryNotBalancedError) {
return {
code: err.code,
details: { totalDebit: err.totalDebit, totalCredit: err.totalCredit, kind: err.kind },
}
}
if (err instanceof FiscalPeriodNotFoundError) return { code: err.code }
if (err instanceof EntryDateOutsideFiscalPeriodError) {
return {
code: err.code,
details: {
entryDate: err.entryDate,
periodName: err.periodName,
periodStart: err.periodStart,
periodEnd: err.periodEnd,
},
}
}
if (err instanceof JournalEntryNotFoundError) return { code: err.code }
if (err instanceof CannotReverseNonPostedError) {
return { code: err.code, details: { currentStatus: err.currentStatus } }
}
if (err instanceof CannotReverseStornoError) {
return { code: err.code, details: { sourceType: err.sourceType } }
}
if (err instanceof CannotCorrectNonPostedError) {
return { code: err.code, details: { currentStatus: err.currentStatus } }
}
if (err instanceof EntryAlreadyReversedError) return { code: err.code }
if (err instanceof CurrencyRevaluationAlreadyExistsError) return { code: err.code }
if (err instanceof InvalidMappingResultError) {
return {
code: err.code,
details: { debitAccount: err.debitAccount, creditAccount: err.creditAccount },
}
}
if (err instanceof MeaninglessCorrectionError) {
return { code: err.code, details: { reason: err.reason } }
}
if (err instanceof NoOpenPeriodForDateError) {
return { code: err.code, details: { date: err.date } }
}
if (err instanceof TargetPeriodClosedError) {
return { code: err.code, details: { date: err.date } }
}
if (err instanceof TargetPeriodLockedError) {
return { code: err.code, details: { date: err.date, lockDate: err.lockDate } }
}
if (err instanceof BookkeepingDatabaseError) {
return { code: err.code, details: { operation: err.operation } }
}
return { code: 'INTERNAL_ERROR' }
}
function buildResponse(
code: string,
entry: StructuredErrorEntry,
requestId: string | undefined,
details: unknown,
): NextResponse {
const body: ErrorEnvelope = {
error: {
code,
message: entry.message_sv,
message_en: entry.message_en,
...(entry.remediation ? { remediation: entry.remediation } : {}),
...(requestId ? { requestId } : {}),
...(details !== undefined ? { details } : {}),
},
}
const res = NextResponse.json(body, { status: entry.httpStatus })
if (requestId) res.headers.set('X-Request-Id', requestId)
return res
}
/**
* Construct an envelope-shaped error directly from a code (when the route
* already knows the failure mode). Skips dispatch — useful inside a handler
* that wants the standard shape without throwing.
*/
export function errorResponseFromCode(
code: string,
log: MinimalLogger,
ctx: ErrorResponseContext & { reason?: string } = {},
): NextResponse {
const entry = entryFor(code)
log.error(code, ctx.reason ?? entry.message_en, { requestId: ctx.requestId })
const status = ctx.status ?? entry.httpStatus
return buildResponse(
code,
{
...entry,
httpStatus: status,
...(ctx.messageSv ? { message_sv: ctx.messageSv } : {}),
...(ctx.messageEn ? { message_en: ctx.messageEn } : {}),
},
ctx.requestId,
ctx.details,
)
}