Files
accounted/lib/errors/get-structured-error.ts
T
Mattsson bb855d2ddc Add/ai native supp (#385)
* feat(branding): implement dynamic branding in service worker and reports

* feat(auth): enhance API key scopes and add bookkeeping write scope

- Updated transaction write scope description to include additional tools.
- Enhanced reports read scope description to reflect new functionality.
- Introduced bookkeeping write scope with relevant description.
- Updated SCOPE_GROUPS to include bookkeeping domain.
- Modified TOOL_SCOPE_MAP to include new bookkeeping operations.
- Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution.

feat(tests): add unit tests for MCP resource registry

- Created tests for data resources to ensure all required fields are present.
- Added tests for resource query parsing and retrieval.

feat(resources): implement MCP resources for company and accounting data

- Added capabilities resource to expose API key capabilities based on granted scopes.
- Implemented chart of accounts resource to retrieve active BAS chart.
- Created company current resource to fetch active company details.
- Developed active fiscal period resource to check posting eligibility.
- Implemented recent activity resource to fetch latest journal entries, invoices, and transactions.
- Added VAT treatments resource to provide available VAT rates per customer type.

feat(pending-operations): introduce risk tiers for operations

- Added risk level classification for pending operations to determine auto-commit eligibility.
- Implemented functions to classify operation risk levels and identify high-risk operations.

feat(migrations): add actor model and risk tier to pending operations

- Updated pending_operations table to include actor type and risk level columns.
- Enhanced audit_log to mirror actor information for compliance.
- Modified validate_and_increment_api_key function to return actor details.
- Expanded operation types in pending_operations to include new high-risk operations.

* feat: add auto-commit functionality for low-risk pending operations

- Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings.
- Created commitPendingOperation function to handle execution of pending operations with consistent status updates.
- Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds.
- Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality.
- Added SQL migration to update the database schema for new auto-commit settings.

* feat(idempotency): implement idempotency key handling for safe retries and cleanup

* feat: expand API key scopes and pending operations for bookkeeping

- Added 'suppliers:write' scope to API key scopes for supplier invoice management.
- Updated SCOPE_GROUPS to include the new 'suppliers:write' scope.
- Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice.
- Implemented corresponding commit functions for the new operations in the pending operations module.
- Enhanced PendingOperation type to include actor model and risk level attributes.
- Added tests for new functionality, ensuring proper behavior and constraints in the database.

* feat: implement unlockPeriod functionality and related tests

* feat: add agent auto-commit settings and related functionality

* feat: add attention resource with comprehensive summary of outstanding tasks

* feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
2026-05-04 11:12:29 +02:00

184 lines
7.1 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
*
* Used by the MCP server's tool error wrapper. UI callers continue to use the
* string-only getErrorMessage() — this is additive.
*/
import { getErrorMessage } from './get-error-message'
export interface StructuredErrorRemediation {
description: string
tool?: string
args?: Record<string, unknown>
resource?: string
}
export interface StructuredError {
code: string
message_sv: string
message_en: string
remediation?: StructuredErrorRemediation
}
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
}
const ERROR_CODE_REMEDIATION: Record<string, StructuredErrorRemediation> = {
ACCOUNTS_NOT_IN_CHART: {
description: 'One or more BAS accounts referenced are not active in the chart of accounts. Activate them via the bookkeeping settings, or use a different category.',
resource: 'gnubok://chart-of-accounts',
},
JOURNAL_ENTRY_NOT_BALANCED: {
description: 'Debits and credits do not match. Recalculate the lines so totals are equal before retrying.',
},
FISCAL_PERIOD_NOT_FOUND: {
description: 'No fiscal period covers the entry date. Create or extend the relevant period before retrying.',
resource: 'gnubok://period/active',
},
ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: {
description: 'The entry date is outside the active fiscal period. Use a date inside an open period or create one that covers it.',
resource: 'gnubok://period/active',
},
CANNOT_REVERSE_NON_POSTED: {
description: 'Only posted entries can be reversed. Commit the draft first or pick a posted entry.',
},
CANNOT_CORRECT_NON_POSTED: {
description: 'Only posted entries can be corrected. Commit the draft first or pick a posted entry.',
},
ENTRY_ALREADY_REVERSED: {
description: 'Another caller reversed this entry concurrently. Re-fetch the entry list and pick a different one.',
},
PERIOD_NOT_LOCKED: {
description: 'The period must be locked before it can be closed. Call gnubok_lock_period first.',
tool: 'gnubok_lock_period',
},
PERIOD_HAS_UNBOOKED_TRANSACTIONS: {
description: 'The period contains uncategorized business transactions. Categorize or mark them private before locking.',
tool: 'gnubok_list_uncategorized_transactions',
},
YEAR_END_NOT_RUN: {
description: 'Year-end closing must be executed before the period can be closed. Run the year-end procedure first.',
},
INSUFFICIENT_SCOPE: {
description: 'The current API key does not have the required scope. Mint a new key with the missing scope or grant it through the API key settings.',
resource: 'gnubok://capabilities',
},
TRANSACTION_ALREADY_CATEGORIZED: {
description: 'The transaction already has a journal entry. Use gnubok_uncategorize_transaction first if you need to recategorize.',
tool: 'gnubok_uncategorize_transaction',
},
INVOICE_ALREADY_SENT: {
description: 'The invoice is already sent or paid; sending again would create a duplicate.',
},
IDEMPOTENCY_KEY_REUSE: {
description: 'This idempotency_key was previously used with a different request body. Use a fresh UUID for a new operation, or send the original request body to replay.',
},
}
/**
* 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'
let remediation = ERROR_CODE_REMEDIATION[code]
// 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 } : {}),
}
}