bb855d2ddc
* 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
184 lines
7.1 KiB
TypeScript
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 } : {}),
|
|
}
|
|
}
|