import { NextResponse, after } from 'next/server' import { TASKS_EXTENSION_ID, isTaskCapableClient, createMcpTask, resolveMcpTask, taskToWire, type McpTaskRow, } from './tasks' import { extractBearerToken, validateApiKey, createServiceClientNoCookies, hasScope, TOOL_SCOPE_MAP, } from '@/lib/auth/api-keys' import { createLogger } from '@/lib/logger' import { roundOre, sumOre } from '@/lib/money' import type { SupabaseClient } from '@supabase/supabase-js' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' import { buildTransactionEntryLines, createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' import { formatVoucherLabel, hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle' import { eventBus } from '@/lib/events/bus' import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { getBranding } from '@/lib/branding/service' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { calculateGrossMargin, calculateCashPosition, calculateExpenseRatio, calculateAvgPaymentDays, calculateVatLiability, } from '@/lib/reports/kpi' import { generateTrialBalance } from '@/lib/reports/trial-balance' import { ACCOUNT_RUTA, VAT_SETTLEMENT_NET_ACCOUNTS, rutorFromTotals, rcInputTotalsFromDeclaration, calculateVatDeclaration, } from '@/lib/reports/vat-declaration' import { fetchDynamicRuta05Accounts } from '@/lib/reports/vat-revenue-accounts' // The momsdeklaration completeness checks live in core (lib/reports) and are // shared with the web UI's "Kontroll av underlaget" gate. The MCP surface // imports them instead of mirroring them: a hand-rolled copy here is exactly // how the reverse-charge check drifted into an unreachable `ruta48 === 0` test. import { runVatDeclarationChecks, type VatCheckAccountTotals, type VatDeclarationCheck, type VatDeclarationCheckStatus, } from '@/lib/reports/vat-declaration-checks' import { withRcBasisGapFindings, isFilingBlocked, type RcBasisGapScan, } from '@/lib/reports/vat-filing-gate' import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { fetchEntryLines, fetchLinesByEntryIds, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' import { uiWidgets, findUiWidget, WIDGET_MIME_TYPE } from './widgets' import { dataResources, findResource, parseResourceQuery } from './resources' import { buildLedgerContext } from '@/lib/agent-context/ledger-context' import { prompts, findPrompt } from './prompts' import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills' import type { SkillTier } from './skills' import { RECOMMENDED_WORKFLOW_LOADOUTS, assertRecommendedLoadoutsValid } from './recommended-tools' import { canonicalizeToolReferencesInText, projectToolReferences, projectToolReferencesInText, resolveMcpToolNamespace, toCanonicalToolName, toPublicToolName, type McpToolNamespace, } from './tool-namespace' import { getRiskLevel } from '@/lib/pending-operations/risk-tiers' import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks' import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier' import { accountClassTypeConflict } from '@/lib/pending-operations/schemas/account' import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value' import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions' import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings' import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer' import { CreateRecurringScheduleParamsSchema, UpdateRecurringScheduleParamsSchema, } from '@/lib/pending-operations/schemas/recurring-schedule' import { computeInitialRunDate } from '@/lib/invoices/recurring-schedule-service' import { UpdateInvoiceParamsSchema } from '@/lib/pending-operations/schemas/update-invoice' import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft' import { ensureCompanyDimensions, fetchDimensionRegistry, parseDimensionsArg, mergeLineDimensions, resolveDimensionBags, type DimensionResolution, } from './dimensions' import { generateDimensionPnl } from '@/lib/reports/dimension-pnl' import Fuse from 'fuse.js' import { z } from 'zod' import { checkIdempotencyKey, storeIdempotencyResponse, hashRequest, IdempotencyKeyReuseError, } from '@/lib/api/idempotency' import { toToolError, type NextActionHint } from './tool-result' import { addCompanyToNextHint, addCompanyToTopLevelNext, assertMcpCompanyWriteAccess, extractRequestedCompany, isCompanyDependentTool, projectToolInputSchema, resolveMcpCompanyContext, } from './company-routing' import { findSupplierCandidates } from './supplier-candidates' import { assertNoPlaintextPersonnummer } from './staging-pii-guard' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { decryptPersonnummer, maskEmployeeForResponse, maskPersonnummer } from '@/lib/salary/personnummer' import { deriveAgiFilingState, resolveRunAgiKvittensnummer, resolveRunAgiSubmission, type AgiSubmissionState, } from '@/lib/salary/agi-submission-state' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { findMatchingInvoices } from '@/lib/invoices/invoice-matching' import { sanitizeDeliveryRecipientStatuses } from '@/lib/invoices/delivery-recipient-statuses' import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service' import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import' import { RotRutBeslutFileSchema } from '@/lib/api/schemas' import { findMatchingVouchersForInvoice, validateVoucherForInvoiceLink, } from '@/lib/invoices/voucher-matching' import { findMatchingVouchersForSupplierInvoice, validateVoucherForSupplierInvoiceLink, } from '@/lib/invoices/supplier-voucher-matching' import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' import { closePeriod, countUnbookedInPeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service' import { generateSIEExport } from '@/lib/reports/sie-export' import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { getSuggestedCategories, buildMerchantHistory, merchantHistoryFor } from '@/lib/transactions/category-suggestions' import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' import { buildDuplicateBookingClaim } from '@/lib/transactions/categorize-core' import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { getEmailService } from '@/lib/email/service' import { hasCapability, capabilityBlockedError } from '@/lib/entitlements/has-capability' import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys' import { generateInvoiceEmailHtml, generateInvoiceEmailText, generateInvoiceEmailSubject, } from '@/lib/email/invoice-templates' import { completePendingDocumentUpload, createPendingDocumentUpload, uploadDocument, MAX_DOCUMENT_SIZE, } from '@/lib/core/documents/document-service' import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' // Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned // pattern as invoice-inbox above: the CI guard only checks lib/, app/api/, // components/. The two submit tools stage ops whose commit dispatches back into // the skatteverket extension via the registry (lib/pending-operations/commit.ts). import { skvRequest, SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client' import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client' import { buildMomsuppgift, resolveRedovisare } from '@/extensions/general/skatteverket/lib/declaration-prep' import { writeSkatteverketAudit } from '@/extensions/general/skatteverket/lib/audit' import { skvAuthCodeToStructured } from '@/extensions/general/skatteverket/lib/error-map' import { formatRedovisningsperiod } from '@/lib/skatteverket/format' import { createExtensionContext } from '@/lib/extensions/context-factory' import { commitPendingOperation } from '@/lib/pending-operations/commit' import { appendProcessingHistory } from '@/lib/processing-history/append' import { getUserCompanies } from '@/lib/company/context' // ensureInitialized() is called by the extension router (ext/[...path]/route.ts) // which dispatches to this handler: no duplicate call needed here. import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType, VatDeclarationRutor } from '@/types' // ── Actor context ──────────────────────────────────────────── interface ActorContext { type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' id?: string label?: string /** * Stable agent-session identifier from the `Mcp-Session-Id` JSON-RPC header * when present, otherwise null. Used to correlate `mcp.tool_called`, * `mcp.workflow_started`, `mcp.next_hint_followed`, etc. events across a * single agent conversation. Not used for auth. */ sessionId?: string | null /** * Distribution-channel marker from `X-Accounted-Client`, the legacy * `X-Gnubok-Client`, or the `client` query param (e.g. 'openclaw'). * Telemetry-only: same trust level as Mcp-Session-Id, never used for auth or * behavior. */ client?: string | null } // ── JSON-RPC types ─────────────────────────────────────────── interface JsonRpcRequest { jsonrpc: '2.0' id?: string | number method: string params?: Record } interface JsonRpcResponse { jsonrpc: '2.0' id: string | number | null result?: unknown error?: { code: number; message: string; data?: unknown } } // ── MCP Tool definition ────────────────────────────────────── interface McpToolAnnotations { readOnlyHint?: boolean destructiveHint?: boolean idempotentHint?: boolean openWorldHint?: boolean } interface McpTool { name: string // Top-level Tool.title per MCP spec 2025-06-18 (human-facing label for // directory listings; distinct from annotations.title). Short Title Case // noun phrase. Flows out via the tools/list serializer below. title?: string description: string inputSchema: Record outputSchema?: Record annotations: McpToolAnnotations /** Wide or specialized tools discoverable through gnubok_search_tools only. */ catalogVisibility?: 'default' | 'search' _meta?: { ui: { resourceUri: string } } // Result-level UI hint: when set, a call passing render_ui=true gets a // _meta.ui.resourceUri on the RESULT, so the host renders the widget only when // asked. (Contrast _meta above, on the definition, which renders on every call.) uiResourceUri?: string // Tasks extension: when this predicate returns true for a call from a // task-capable client, the dispatcher returns a CreateTaskResult and runs // execute() after the response instead of blocking on it. Not serialized // into tools/list. shouldRunAsTask?: (args: Record) => boolean execute: ( args: Record, companyId: string, userId: string, supabase: SupabaseClient, actor?: ActorContext ) => Promise } // ── Shared constants ───────────────────────────────────────── const log = createLogger('mcp-server') // gnubok_feedback rate limit: 1 per 60s per actor. In-memory single-process; // no Redis dependency. See the gnubok_feedback tool definition below. const FEEDBACK_RATE_LIMIT_MS = 60_000 const feedbackRateLimit = new Map() const VALID_CATEGORIES = [ 'income_services', 'income_products', 'income_other', 'expense_equipment', 'expense_software', 'expense_travel', 'expense_office', 'expense_marketing', 'expense_professional_services', 'expense_education', 'expense_representation', 'expense_consumables', 'expense_vehicle', 'expense_telecom', 'expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange', 'expense_other', 'private', ] as const const VALID_VAT_TREATMENTS = [ 'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt', ] as const const MCP_DOCUMENT_MIME_TYPES = [ 'application/pdf', 'image/jpeg', 'image/png', 'image/heic', 'image/webp', ] as const const MCP_DOCUMENT_MIME_TYPE_SET = new Set(MCP_DOCUMENT_MIME_TYPES) function resolveMcpDocumentMimeType(fileName: string, requestedMimeType: unknown): string { let mimeType = typeof requestedMimeType === 'string' ? requestedMimeType : undefined if (!mimeType) { const extension = fileName.split('.').pop()?.toLowerCase() const mimeMap: Record = { pdf: 'application/pdf', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', heic: 'image/heic', webp: 'image/webp', } mimeType = extension ? mimeMap[extension] : undefined if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${extension}`) } if (!MCP_DOCUMENT_MIME_TYPE_SET.has(mimeType)) { throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`) } return mimeType } interface DocumentInboxResult { document_id: string inbox_item_id: string status: string extracted_data: Record matched_supplier_id: string | null } async function findCompletedDocumentInboxItem( supabase: SupabaseClient, companyId: string, userId: string, inboxItemId: string ): Promise { const { data, error } = await supabase .from('invoice_inbox_items') .select('id, document_id, status, extracted_data, matched_supplier_id') .eq('id', inboxItemId) .eq('company_id', companyId) .eq('user_id', userId) .maybeSingle() if (error) throw new Error(`Failed to check completed document upload: ${error.message}`) if (!data) return null if (data.document_id !== inboxItemId) { throw new Error('Upload ID collides with an unrelated inbox item') } return { document_id: data.document_id, inbox_item_id: data.id, status: data.status, extracted_data: (data.extracted_data ?? {}) as Record, matched_supplier_id: data.matched_supplier_id, } } async function createDocumentInboxItem( supabase: SupabaseClient, companyId: string, userId: string, documentId: string, fileName: string, mimeType: string, buffer: Buffer, reservedInboxItemId?: string ): Promise { if (reservedInboxItemId) { const existing = await findCompletedDocumentInboxItem( supabase, companyId, userId, reservedInboxItemId, ) if (existing) return existing } const { data: extracted } = await extractInvoiceFields({ buffer, mimeType, fileName }) let matchedSupplierId: string | null = null if (extracted.supplier.orgNumber) { const { data: supplier } = await supabase .from('suppliers') .select('id') .eq('company_id', companyId) .eq('org_number', extracted.supplier.orgNumber) .limit(1) .maybeSingle() if (supplier) matchedSupplierId = supplier.id } const { data: inbox, error: inboxError } = await supabase .from('invoice_inbox_items') .insert({ // Literal payload keeps the no-phantom-columns scanner able to resolve // every column; the legacy path gets an explicit UUID instead of the DB // default. id: reservedInboxItemId ?? crypto.randomUUID(), company_id: companyId, user_id: userId, status: 'received', source: 'upload', document_id: documentId, extracted_data: extracted as unknown as Record, matched_supplier_id: matchedSupplierId, }) .select('id, status') .single() if (inboxError) { if (reservedInboxItemId) { const concurrent = await findCompletedDocumentInboxItem( supabase, companyId, userId, reservedInboxItemId, ) if (concurrent) return concurrent } throw new Error(`Failed to create inbox item: ${inboxError.message}`) } return { document_id: documentId, inbox_item_id: inbox.id, status: inbox.status, extracted_data: extracted as unknown as Record, matched_supplier_id: matchedSupplierId, } } // ── Pending operations staging ─────────────────────────────── /** * Param-keys we'll scan for an affärshändelse date when the caller doesn't * pass `dateForPeriodCheck` explicitly. Ordered: most-specific first. The * first ISO yyyy-MM-dd hit wins. Adding a new field is safe: unknown values * just fall through to undefined. */ const AUTO_PERIOD_DATE_KEYS = [ 'entry_date', 'payment_date', 'invoice_date', 'date', 'period_end', 'period_start', 'voucher_date', 'paid_date', 'transfer_date', ] as const const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ function autoExtractDateForPeriodCheck(params: Record): string | undefined { for (const key of AUTO_PERIOD_DATE_KEYS) { const value = params[key] if (typeof value === 'string' && ISO_DATE_RE.test(value)) return value } return undefined } interface StageOptions { /** * When true, validate inputs and return the would-be preview without * inserting into pending_operations or executing any side-effects. Used * by agents to preflight an operation before committing to it. */ dryRun?: boolean /** * Per-operation idempotency key. When supplied, repeat calls with the same * key + same payload return the original response and never re-execute. * Different payload + same key returns IDEMPOTENCY_KEY_REUSE. */ idempotencyKey?: string /** * ISO yyyy-MM-dd date used to look up period_status before staging. When * provided, the response includes a `period_status` envelope so agents and * widgets can detect locked/closed periods without a round-trip. Failure to * resolve (DB blip, missing settings row) leaves the response unchanged: * the DB triggers remain the authoritative gate. */ dateForPeriodCheck?: string } function buildApprovalGuidance(operationId: string, riskLevel: 'low' | 'medium' | 'high'): string { if (riskLevel === 'high') { return `This is an irreversible posting under BFL 5 kap 5§: surface the irreversibility implications to the user and obtain an explicit acknowledgment before committing. Once the user has acknowledged, call gnubok_approve_pending_operation with operation_id="${operationId}" and confirmed=true.` } return `When the user authorises, call gnubok_approve_pending_operation with operation_id="${operationId}".` } async function stagePendingOperation( supabase: SupabaseClient, companyId: string, userId: string, operationType: string, title: string, params: Record, previewData: Record, actor: ActorContext = { type: 'user' }, next?: NextActionHint, options: StageOptions = {} ): Promise<{ staged: boolean dry_run?: boolean idempotency_replay?: boolean operation_id?: string risk_level: 'low' | 'medium' | 'high' actor: ActorContext message: string approve?: { tool: string; args: Record } preview: Record period_status?: PeriodStatusForDate next?: NextActionHint }> { // PII chokepoint (ISO 27001 A.8.11): no staged payload may persist a // plaintext personnummer. Enforced here so every current and future // staging tool inherits the rule, not just the ones that remembered it. assertNoPlaintextPersonnummer(params, 'params') assertNoPlaintextPersonnummer(previewData, 'preview_data') // params-aware: create/update_recurring_schedule escalate to 'high' when // params.auto_send === true (standing outbound email with no per-send // approval, same side-effect that puts one-off send_invoice at 'high'). // Ops whose persisted params nest the effective fields under `changes` // (update_recurring_schedule: { schedule_id, changes }) are flattened for // the risk check ONLY, so paramEscalatedRisk sees auto_send; the stored // params row is untouched (the commit executor's schema owns that shape). const changesBag = params.changes const riskParams = changesBag && typeof changesBag === 'object' && !Array.isArray(changesBag) ? { ...params, ...(changesBag as Record) } : params const riskLevel = getRiskLevel(operationType, riskParams) const branding = getBranding().appName.toLowerCase() // Resolve period_status once. The caller can pass `dateForPeriodCheck` // explicitly; otherwise we scan params for a known affärshändelse-date // field so every date-bearing operation surfaces a period_status envelope // without each tool having to opt in. Failure is non-fatal: DB triggers // are the authoritative gate; a missing envelope just degrades preview UX. const dateForPeriodCheck = options.dateForPeriodCheck ?? autoExtractDateForPeriodCheck(params) let periodStatus: PeriodStatusForDate | undefined if (dateForPeriodCheck) { try { periodStatus = await resolvePeriodStatusForDate(supabase, companyId, dateForPeriodCheck) } catch (err) { log.warn('resolvePeriodStatusForDate failed', { operationType, companyId, dateForPeriodCheck, error: err instanceof Error ? err.message : String(err), }) periodStatus = undefined } } // ── Dry-run path: skip both the cache and the insert. Return the preview // so the agent sees exactly what would happen without committing. if (options.dryRun) { return { staged: false, dry_run: true, risk_level: riskLevel, actor, message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`, preview: previewData, ...(periodStatus ? { period_status: periodStatus } : {}), ...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}), } } // ── Idempotency check: same key + same payload + same company → return // cached response. companyId is folded into the canonical hash so the // same key UUID submitted under a different company is treated as a // fresh request, not a replay. const requestHash = options.idempotencyKey ? hashRequest({ operationType, params, companyId }) : null if (options.idempotencyKey && requestHash) { const cached = await checkIdempotencyKey(supabase, userId, companyId, options.idempotencyKey, requestHash) if (cached) { const cachedBody = cached.body as Record const cachedOpId = typeof cachedBody.operation_id === 'string' ? cachedBody.operation_id : undefined return { ...cachedBody, idempotency_replay: true, risk_level: riskLevel, actor, message: cachedOpId ? `Replayed cached response for idempotency_key "${options.idempotencyKey}": already staged as pending_operation ${cachedOpId}. No new side-effects. ${buildApprovalGuidance(cachedOpId, riskLevel)}` : `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`, ...(cachedOpId ? { approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: cachedOpId, company_id: companyId }, }, } : {}), preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData, ...(periodStatus ? { period_status: periodStatus } : {}), } as Awaited> } } const { data, error } = await supabase .from('pending_operations') .insert({ company_id: companyId, user_id: userId, operation_type: operationType, title, params, preview_data: previewData, actor_type: actor.type, actor_id: actor.id ?? null, actor_label: actor.label ?? null, risk_level: riskLevel, }) .select('*') .single() if (error) throw new Error(`Failed to stage operation: ${error.message}`) // approve.args carries only the operation_id. For high-risk operations the // LLM must supply confirmed=true itself after surfacing the BFL 5 kap 5§ // irreversibility implications to the user: pre-filling it server-side // would collapse the explicit-acknowledgment gate (mirrors the web UI's // warning dialog). The server-side check in gnubok_approve_pending_operation // remains authoritative. const response = { staged: true, operation_id: data.id, risk_level: riskLevel, actor, message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`, approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: data.id, company_id: companyId } as Record, }, preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData, ...(periodStatus ? { period_status: periodStatus } : {}), ...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}), } as const if (options.idempotencyKey && requestHash) { await storeIdempotencyResponse( supabase, userId, companyId, options.idempotencyKey, requestHash, 'success', { staged: true, operation_id: data.id, preview: previewData } ) } return response } // ── Skatteverket filing helpers (PR5) ──────────────────────── // // Direct lib calls bypass the HTTP dispatcher's SKATTEVERKET_ENABLED gate, so // every Skatteverket tool gates on it first (before any DB/SKV access). // mapSkatteverketError re-attaches a registry code to a SkatteverketAuthError // so toToolError/getStructuredError surface the right structured envelope + // reconnect remediation (the raw SKV codes aren't registry entries). function assertSkatteverketEnabled(): void { if (process.env.SKATTEVERKET_ENABLED !== 'true') { const err = new Error('Skatteverket-integrationen är inte aktiverad i denna miljö.') as Error & { code: string } err.code = 'EXTENSION_DISABLED' throw err } } function mapSkatteverketError(err: unknown): Error { if (err instanceof SkatteverketAuthError) { const mapped = skvAuthCodeToStructured(err.code) const out = new Error(err.message) as Error & { code: string } out.code = mapped.code return out } return err instanceof Error ? err : new Error(String(err)) } /** * Count ERROR-level findings in a /kontrollera response body. * * Skatteverket wraps them in `kontrollResultat.resultat` (Momsdeklaration * v1.0.24 RAML, note SKV's mixed casing); the bare `resultat` fallback exists * so an unwrapped body can never silently read as "no errors". Same convention * as the shipped filing chain (skatteverket/lib/vat-submit.ts). * * A green count here means the ARITHMETIC is consistent. It is not a statement * about whether the declaration reflects the books: see the module header of * lib/reports/vat-declaration-checks.ts. */ function countSkvKontrollErrors(body: unknown): number { if (!body || typeof body !== 'object') return 0 const root = body as Record const wrapped = root.kontrollResultat as Record | undefined const findings = (wrapped?.resultat ?? root.resultat) as unknown const list = Array.isArray(findings) ? findings : [] const errors = list.filter( (f) => (f as { status?: string } | null)?.status === 'ERROR' ).length if (errors > 0) return errors // A top-level status of ERROR with no itemised findings still means rejected. const status = (wrapped?.status ?? root.status) as string | undefined return status === 'ERROR' ? 1 : 0 } /** YYYYMM → last day of that month as yyyy-MM-dd (for dateForPeriodCheck). */ function skvPeriodToEndDate(redovisningsperiod: string): string { const year = Number(redovisningsperiod.slice(0, 4)) const month = Number(redovisningsperiod.slice(4, 6)) const lastDay = new Date(year, month, 0).getDate() return `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` } // ── Journal entry reference resolution ──────────────────────── /** * Resolve a journal entry reference to a journal_entries.id UUID. * * Accepts either a raw UUID (returned as-is) or a voucher reference like * "A-113" / "A113" / "A/113" (resolved by voucher_series + voucher_number * scoped to the company). * * Voucher refs are the preferred input shape for LLM-driven callers: short, * semantically meaningful, and resistant to UUID hallucination: a failure * mode where the agent reproduces the first 8 hex chars correctly but * fabricates the remaining 24, so a downstream lookup rejects the ID even * though the entry exists. */ async function resolveJournalEntryRef( supabase: SupabaseClient, companyId: string, ref: string ): Promise { const trimmed = ref.trim() // UUIDs pass through. If the UUID was hallucinated, the caller's own // lookup surfaces the "not found" diagnostic with the supplied value. if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)) { return trimmed } // Voucher ref: letters (series) + optional separator + digits (number). const match = trimmed.match(/^([A-Za-z]+)\s*[-:/ ]?\s*(\d+)$/) if (!match) { throw new Error( `Could not parse entry reference "${ref}". Expected a UUID or a voucher ref like "A-113".` ) } const series = match[1].toUpperCase() const number = parseInt(match[2], 10) const { data, error } = await supabase .from('journal_entries') .select('id, entry_date, description') .eq('company_id', companyId) .eq('voucher_series', series) .eq('voucher_number', number) .order('entry_date', { ascending: false }) if (error) { throw new Error(`Database error resolving voucher "${series}-${number}": ${error.message}`) } const matches = (data ?? []) as Array<{ id: string; entry_date: string; description: string }> if (matches.length === 0) { throw new Error( `No journal entry found for voucher "${series}-${number}" in this company. ` + `Verify the series and number, or supply the full UUID.` ) } // Voucher numbers reset per fiscal period. The same (series, number) pair // can therefore appear in multiple years: refuse to guess. if (matches.length > 1) { const summary = matches .map((m) => `${m.entry_date} "${m.description}" (id=${m.id})`) .join('; ') throw new Error( `Voucher "${series}-${number}" matches multiple entries across fiscal periods: ${summary}. ` + `Supply the specific UUID instead.` ) } return matches[0].id } // ── Shared categorization logic ────────────────────────────── // ── Lock-period staging guard ──────────────────────────────────────────────── // // The staging pre-check runs the exact same countUnbookedInPeriod the commit // path (lockPeriod) enforces, imported from period-service so the two legal // guards cannot drift apart. See the DECISIONS.md 2026-07-26 lock-guard entry // for the predicate semantics. async function categorizeTransactionCore( txId: string, category: TransactionCategory, vatTreatment: VatTreatment | undefined, // Underlag's actual VAT when it differs from rate × belopp (e.g. dricks on // a restaurant receipt carries no moms). Replaces the computed VAT line. vatAmount: number | undefined, userId: string, companyId: string, supabase: SupabaseClient, confirm: boolean = false ): Promise<{ preview?: boolean success?: boolean journal_entry_created?: boolean journal_entry_id?: string | null journal_entry_error?: string | null category: string debit_account: string credit_account: string amount: number currency: string vat_lines?: Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }> // The exact journal lines the commit executor will post (net cost line, // VAT line, gross bank line) — always in SEK, matching the booked entry. lines?: Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }> message?: string transaction?: Transaction underlag?: { document_id: string total: number | null vat_amount: number | null currency: string | null } | null }> { // Validate category if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) { throw new Error( `Invalid category "${category}". Valid categories: ${VALID_CATEGORIES.join(', ')}` ) } if (vatTreatment && !VALID_VAT_TREATMENTS.includes(vatTreatment as typeof VALID_VAT_TREATMENTS[number])) { throw new Error( `Invalid vat_treatment "${vatTreatment}". Valid: ${VALID_VAT_TREATMENTS.join(', ')}` ) } const isBusiness = category !== 'private' // Fetch the transaction const { data: transaction, error: fetchError } = await supabase .from('transactions') .select('*') .eq('id', txId) .eq('company_id', companyId) .single() if (fetchError || !transaction) { throw new Error('Transaction not found. Check the transaction_id is correct.') } // Underlag guard: if the transaction has an attached document with // AI-extracted invoice data, use it to validate the proposed VAT treatment // BEFORE we build the booking. The historical failure mode here was the // agent stamping reverse_charge on any foreign-vendor charge and producing // fictive 2645/2614 VAT lines (25% of the SEK amount) on an invoice where // the seller had already debited real VAT. Block that explicitly. let underlagSummary: { document_id: string total: number | null vat_amount: number | null currency: string | null } | null = null if (transaction.document_id) { const { data: doc } = await supabase .from('document_attachments') .select('id, extracted_data') .eq('id', transaction.document_id) .eq('company_id', companyId) .maybeSingle() const ex = (doc?.extracted_data ?? null) as | { totals?: { total?: number; vatAmount?: number }; invoice?: { currency?: string } } | null if (doc) { underlagSummary = { document_id: doc.id as string, total: ex?.totals?.total ?? null, vat_amount: ex?.totals?.vatAmount ?? null, currency: ex?.invoice?.currency ?? null, } const sellerChargedVat = (ex?.totals?.vatAmount ?? 0) > 0 if (vatTreatment === 'reverse_charge' && sellerChargedVat) { throw new Error( `Reverse charge avvisas: underlaget (document_id=${doc.id}) visar att säljaren redan har debiterat moms ` + `(${ex?.totals?.vatAmount} ${ex?.invoice?.currency ?? ''}). Omvänd skattskyldighet gäller bara fakturor utan säljarens moms. ` + `Bokför som vanlig kostnad (utan vat_treatment, eller standard_25 om svensk faktura): den utländska momsen ingår i kostnaden.`, ) } } } if (transaction.journal_entry_id) { return { success: true, journal_entry_created: false, journal_entry_id: transaction.journal_entry_id, journal_entry_error: 'Transaction already has a journal entry: use gnubok_list_uncategorized_transactions to find unbooked ones.', category, debit_account: '', credit_account: '', amount: Math.abs(transaction.amount), currency: transaction.currency, transaction: transaction as Transaction, } } // Get entity type const { data: settings } = await supabase .from('company_settings') .select('entity_type, fiscal_year_start_month') .eq('company_id', companyId) .single() const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' // Build mapping let mappingResult = buildMappingResultFromCategory( category, transaction as Transaction, isBusiness, entityType, vatTreatment, vatAmount ) const settlementAccount = await resolveSettlementAccount( supabase, companyId, transaction.cash_account_id, log, ) mappingResult = applySettlementAccount(mappingResult, settlementAccount) if (!mappingResult.debit_account || !mappingResult.credit_account) { throw new Error( `No account mapping for category "${category}" with entity type "${entityType}". ` + 'Try a different category or check your chart of accounts.' ) } // Preview mode: return what would happen without executing if (!confirm) { // Materialize the exact lines the commit executor will post — including // the gross→net split on the cost line. Historically the preview only // carried { debit/credit account, gross amount, vat_lines }, which reads // as an unbalanced "gross on cost account + VAT debit" entry and misled // both users and agents into rejecting correct proposals. const entryLines = buildTransactionEntryLines(transaction as Transaction, mappingResult) return { preview: true, category, debit_account: mappingResult.debit_account, credit_account: mappingResult.credit_account, amount: Math.abs(transaction.amount), currency: transaction.currency, lines: entryLines.map(l => ({ account_number: l.account_number, debit_amount: l.debit_amount, credit_amount: l.credit_amount, description: l.line_description ?? '', })), vat_lines: mappingResult.vat_lines.map(v => ({ account_number: v.account_number, debit_amount: v.debit_amount, credit_amount: v.credit_amount, description: v.description, })), message: 'Preview only: no changes made. Call again with confirm: true to create the journal entry.', underlag: underlagSummary, } } // Ensure fiscal period exists const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1 const txDate = new Date(transaction.date) const txMonth = txDate.getMonth() + 1 const txYear = txDate.getFullYear() let periodStartYear: number if (fiscalYearStartMonth === 1) { periodStartYear = txYear } else if (txMonth >= fiscalYearStartMonth) { periodStartYear = txYear } else { periodStartYear = txYear - 1 } const startMonth = String(fiscalYearStartMonth).padStart(2, '0') const periodStart = `${periodStartYear}-${startMonth}-01` const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1 const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1 const lastDay = new Date(endYear, endMonth, 0).getDate() const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` const periodName = fiscalYearStartMonth === 1 ? `Räkenskapsår ${periodStartYear}` : `Räkenskapsår ${periodStartYear}/${endYear}` await supabase .from('fiscal_periods') .upsert( { user_id: userId, name: periodName, period_start: periodStart, period_end: periodEnd }, { onConflict: 'user_id,period_start,period_end' } ) // Create journal entry let journalEntryId: string | null = null let journalEntryError: string | null = null try { const journalEntry = await createTransactionJournalEntry( supabase, companyId, userId, transaction as Transaction, mappingResult ) if (journalEntry) { journalEntryId = journalEntry.id } } catch (err) { journalEntryError = err instanceof Error ? err.message : 'Unknown error' } // Update transaction await supabase .from('transactions') .update({ is_business: isBusiness, category, journal_entry_id: journalEntryId, }) .eq('id', txId) // Emit event so extensions (mapping rules, etc.) can react await eventBus.emit({ type: 'transaction.categorized', payload: { transaction: transaction as Transaction, account: mappingResult.debit_account, taxCode: mappingResult.vat_lines[0]?.account_number || '', userId, companyId, }, }) // Upsert counterparty template for future auto-matching try { await upsertCounterpartyTemplate( supabase, companyId, transaction as Transaction, mappingResult, 'user_approved' ) } catch { // Non-critical } return { success: true, journal_entry_created: !!journalEntryId, journal_entry_id: journalEntryId, journal_entry_error: journalEntryError, category, debit_account: mappingResult.debit_account, credit_account: mappingResult.credit_account, amount: Math.abs(transaction.amount), currency: transaction.currency, transaction: transaction as Transaction, } } // ── Output schema helpers ──────────────────────────────────── const PAGINATION_PROPS = { count: { type: 'number', description: 'Number of items in this page' }, total_count: { type: 'number', description: 'Total matching across all pages' }, has_more: { type: 'boolean' }, next_offset: { type: 'number', description: 'Offset for the next page (omitted on last page)' }, } as const const NEXT_ACTION_HINT_SCHEMA = { type: 'object', additionalProperties: false, properties: { description: { type: 'string' }, tool: { type: 'string' }, args: { type: 'object', additionalProperties: true }, resource: { type: 'string' }, }, required: ['description'], } as const const STAGED_OPERATION_SCHEMA = { type: 'object', properties: { staged: { type: 'boolean' }, operation_id: { type: 'string', description: 'UUID of the staged operation, present once persisted' }, risk_level: { type: 'string', enum: ['low', 'medium', 'high'] }, actor: { type: 'object' }, dry_run: { type: 'boolean' }, idempotency_replay: { type: 'boolean' }, message: { type: 'string' }, approve: { type: 'object' }, preview: { type: 'object' }, period_status: { type: 'object', description: 'Fiscal period covering the affärshändelse date. Use to detect locked/closed periods without a round-trip.', properties: { period_id: { type: ['string', 'null'] }, status: { type: 'string', enum: ['open', 'locked', 'closed'] }, lock_date: { type: ['string', 'null'] }, }, }, next: NEXT_ACTION_HINT_SCHEMA, }, required: ['staged', 'risk_level', 'actor', 'message', 'preview'], } as const /** * Staging writes that have a read-only pre-flight an agent should run first. * Surfaced as `_meta.preflight` in tools/list so the preview/validate step is * discoverable from the staging tool itself, not just from prose. Keep entries * to genuine pre-flights (a tool that returns a verdict/proposal before the * irreversible write), not recovery/undo tools. */ const TOOL_PREFLIGHT_MAP: Record = { gnubok_run_year_end: 'gnubok_year_end_readiness', gnubok_vat_declaration_submit: 'gnubok_vat_declaration_validate', gnubok_post_annual_depreciation: 'gnubok_propose_annual_depreciation', gnubok_book_salary_run: 'gnubok_get_salary_run', } /** * Discovery-time metadata derived from a tool definition, surfaced under `_meta` * in tools/list (and gnubok_search_tools detail=full). Lets an agent tell * (WITHOUT reading prose) whether a write stages for approval and whether a * pre-flight exists. `requires_approval` keys off the staged-operation output * schema, the single source of truth for "this write produces a * pending_operation you must commit via approve_tool". Returns undefined for * tools with no staging contract (reads, direct-commit approve/reject) so we * don't bloat the catalog with empty objects. */ export function deriveToolMeta(t: { name: string; outputSchema?: Record }): Record | undefined { if (t.outputSchema !== STAGED_OPERATION_SCHEMA) return undefined const preflight = TOOL_PREFLIGHT_MAP[t.name] return { requires_approval: true, approve_tool: 'gnubok_approve_pending_operation', ...(preflight ? { preflight } : {}), } } export function isDefaultCatalogTool(tool: { catalogVisibility?: 'default' | 'search' }): boolean { return tool.catalogVisibility !== 'search' } function paginatedSchema(itemsKey: string, itemSchema: Record = { type: 'object' }) { return { type: 'object', properties: { [itemsKey]: { type: 'array', items: itemSchema }, ...PAGINATION_PROPS, }, required: [itemsKey, 'count', 'total_count', 'has_more'], } as const } const VAT_REPORT_OUTPUT_SCHEMA = { type: 'object', properties: { period: { type: 'object', additionalProperties: false, properties: { type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'] }, year: { type: 'number' }, period: { type: 'number' }, start: { type: 'string', description: 'Period start date (YYYY-MM-DD)' }, end: { type: 'string', description: 'Period end date (YYYY-MM-DD)' }, }, required: ['type', 'year', 'period', 'start', 'end'], }, period_label: { type: 'string', description: 'Human-readable period label (e.g. "Q1 2026")' }, rutor: { type: 'object', description: 'SKV 4700 momsdeklaration boxes: absolute values, signs implied by box semantics', properties: { ruta05: { type: 'number', description: 'Total domestic taxable sales (all rates)' }, ruta10: { type: 'number', description: 'Output VAT 25 % (account 2611)' }, ruta11: { type: 'number', description: 'Output VAT 12 % (account 2621)' }, ruta12: { type: 'number', description: 'Output VAT 6 % (account 2631)' }, ruta30: { type: 'number', description: 'Reverse-charge output VAT 25 % (account 2614)' }, ruta31: { type: 'number', description: 'Reverse-charge output VAT 12 % (account 2624)' }, ruta32: { type: 'number', description: 'Reverse-charge output VAT 6 % (account 2634)' }, ruta35: { type: 'number', description: 'EU intra-community goods supplies, momsfri (account 3108)' }, ruta39: { type: 'number', description: 'EU services sold (account 3308)' }, ruta40: { type: 'number', description: 'Export outside EU (account 3305)' }, ruta48: { type: 'number', description: 'Total input VAT (2641 + 2645 + 2647)' }, ruta49: { type: 'number', description: 'VAT to pay (positive) or refund (negative) = (10+11+12+30+31+32+60+61+62) − 48', }, ruta60: { type: 'number', description: 'Import VAT 25 % (account 2615): non-EU import declared via momsdeklaration' }, ruta61: { type: 'number', description: 'Import VAT 12 % (account 2625)' }, ruta62: { type: 'number', description: 'Import VAT 6 % (account 2635)' }, }, required: ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49', 'ruta60', 'ruta61', 'ruta62'], }, summary: { type: 'string', description: 'One-line Swedish summary string (att betala / att få tillbaka / noll)' }, warnings: { type: 'array', items: { type: 'string' }, description: 'Pre-filing warnings (e.g. one-sided reverse charge). Empty when none.', }, }, required: ['period', 'period_label', 'rutor', 'summary', 'warnings'], } as const // ── Skatteverket filing read-tool output schemas (PR5) ── // Kept shallow (opaque object/null sub-objects) to stay within the tools/list // payload budget; the SKV response shapes live in the extension types. const SKV_VAT_VALIDATE_OUTPUT_SCHEMA = { type: 'object', properties: { redovisare: { type: 'string', description: '12-digit redovisare' }, redovisningsperiod: { type: 'string', description: 'YYYYMM' }, momsuppgift: { type: 'object', description: 'The momsuppgift payload sent to Skatteverket' }, kontrollresultat: { type: 'object', description: 'Skatteverket kontrollresultat (status + per-ruta fel/varningar)' }, arithmetic_ok: { type: 'boolean', description: 'Skatteverket found no ERROR: the payload adds up. Says NOTHING about whether the underlag is complete.', }, completeness_ok: { type: 'boolean', description: 'Local pre-flight found no ERROR. False = materially incomplete (e.g. FK004) even when arithmetic_ok is true.', }, completeness_checks: { type: 'array', items: { type: 'object' }, description: 'Local findings: { code, status (ERROR|WARNING), message (Swedish), rutor }.', }, summary: { type: 'string', description: 'One-line Swedish verdict for both results.' }, }, required: [ 'redovisare', 'redovisningsperiod', 'momsuppgift', 'kontrollresultat', 'arithmetic_ok', 'completeness_ok', 'completeness_checks', 'summary', ], } as const const SKV_VAT_STATUS_OUTPUT_SCHEMA = { type: 'object', properties: { redovisare: { type: 'string', description: '12-digit redovisare' }, redovisningsperiod: { type: 'string', description: 'YYYYMM' }, submitted: { type: ['object', 'null'], description: 'Inlämnad deklaration, or null if none on file' }, decided: { type: ['object', 'null'], description: 'Beslutad deklaration, or null if not yet decided' }, }, required: ['redovisare', 'redovisningsperiod', 'submitted', 'decided'], } as const const SKV_AGI_STATUS_OUTPUT_SCHEMA = { type: 'object', properties: { salary_run_id: { type: 'string' }, period: { type: 'string', description: 'YYYYMM' }, filing_state: { type: 'string', enum: ['none', 'generated', 'underlag_submitted', 'awaiting_signing', 'signed'], description: "Run-scoped: a correction run reports its own state, never the superseded original run's.", }, kvittensnummer: { type: ['string', 'null'], description: "Kvittens for THIS run's signed AGI; null until signed.", }, local_state: { type: ['object', 'null'], description: 'Run-scoped cached submission record; null when the period record belongs to a sibling run.', }, kvittenser: { type: ['array', 'null'], description: 'Signed receipts from Skatteverket, or null when unavailable' }, }, required: ['salary_run_id', 'period', 'filing_state', 'kvittensnummer', 'local_state', 'kvittenser'], } as const // ── VAT report computation (shared by gnubok_get_vat_report + gnubok_vat_review_widget) ── // // Maps posted journal entry lines to SKV 4700 rutor. ruta49 covers domestic // output VAT (10/11/12) AND reverse-charge output VAT (30/31/32) per // ML 2023:200: both must be displayed and netted against ruta48 (input VAT). // // Account → ruta map: // 3001-3008, 3041-3048, 3051-3058, 3071-3078 → ruta05 (all domestic taxable sales, common BAS revenue accounts) // 2611 → ruta10 (output VAT 25%) // 2621 → ruta11 (output VAT 12%) // 2631 → ruta12 (output VAT 6%) // 2614 → ruta30 (reverse-charge output VAT 25%) // 2624 → ruta31 (reverse-charge output VAT 12%) // 2634 → ruta32 (reverse-charge output VAT 6%) // 3308 → ruta39 (EU services sold) // 3305 → ruta40 (export outside EU) // 2641/2645/2647 → ruta48 (all input VAT) // // Posted+reversed status filter: a "reversed" original entry is still part of // its period's books: Skatteverket files VAT period-by-period under // faktureringsmetoden (sale's VAT in invoice-date period; kreditfaktura's // reduction in storno-date period). The original entry stays in its period; // the storno (status 'posted', dated when the credit was issued) lands in // its own period. The two periods file independently; across a year they // arithmetically cancel. *Excluding* 'reversed' would under-report Period N // (the original sale's VAT silently disappears) and over-credit Period N+M // (a reversal with no original), incorrect per ML 2023:200. /** Common BAS taxable-revenue accounts that contribute to ruta 05. * * Conservative expansion beyond 3001/3002/3003. Excludes 3004 (momsfri, * exempt) and 3108/3305/3308 (handled by ruta35/40/39). 3106 covers the * rare case of taxable EU goods (momspliktig EU-leverans, e.g. when the * buyer's VAT number is invalid). * * This hand-maintained widening predates #1261 and is kept so no company * loses a figure it already saw. It is no longer the only path: a company's * own class 3 konto marked with a moms-sats is resolved at runtime by * fetchDynamicRuta05Accounts and unioned in below, which is what actually * covers non-standard charts (Accounted's BAS chart ships no varugrupp * accounts at all). */ const RUTA_05_ACCOUNTS = [ // The 30xx gruppkonto. ACCOUNT_RUTA maps it to ruta05, so leaving it out here // made a balance on 3000 appear in the filed projection but not in // report.rutor.ruta05. '3000', // Domestic sales by VAT rate (canonical BAS) '3001', '3002', '3003', '3005', '3006', '3007', '3008', // Taxable EU goods (momspliktig, buyer's VAT number invalid or buyer is private) '3106', // Domestic services (alternative numbering some companies use) '3041', '3042', '3043', '3044', '3045', '3046', '3047', '3048', // Domestic goods (alternative numbering) '3051', '3052', '3053', '3054', '3055', '3056', '3057', '3058', // Other domestic taxable '3071', '3072', '3073', '3074', '3075', '3076', '3077', '3078', ] as const export interface VatReportResult { period: { type: string; year: number; period: number; start: string; end: string } period_label: string rutor: { ruta05: number; ruta10: number; ruta11: number; ruta12: number ruta30: number; ruta31: number; ruta32: number ruta35: number; ruta39: number; ruta40: number ruta48: number; ruta49: number // Import VAT (post-2015 momsdeklaration path, accounts 2615/2625/2635). // Buyer/importer self-assesses output VAT here and deducts the matching // input via ruta 48: same mechanic as ruta 30/31/32. ruta60: number; ruta61: number; ruta62: number } summary: string warnings: string[] } export interface VatReportWithRutor { report: VatReportResult /** * The FULL SKV 4700 projection of the same ledger aggregate, via core's * `rutorFromTotals`. `report.rutor` is the trimmed agent-facing view: it has * no rutor 20-24 (beskattningsunderlag vid omvänd skattskyldighet) and no * ruta 50 (underlag vid import), which are exactly the boxes the * completeness checks in lib/reports/vat-declaration-checks.ts compare * against rutor 30-32 / 60-62. * * The two also differ on ruta 05 by design: `report.rutor.ruta05` sums the * widened RUTA_05_ACCOUNTS list for display, while this one is the canonical * ACCOUNT_RUTA projection, i.e. what would actually be filed. Checks run on * the filed shape, never on the display shape. The company's own ruta 05 * accounts feed BOTH: they are part of the filing, not a display widening. */ declarationRutor: VatDeclarationRutor /** * The per-account debit/credit totals both projections above are built from, * exactly the shape `runVatDeclarationChecks` takes as its optional second * argument. Threaded through so the completeness checks compare rutor 30-32 * against the reverse-charge INPUT accounts (2645/2647) rather than the ruta * 48 aggregate, which ordinary debiterad ingående moms on 2641 masks. Internal * to the server: no tool puts this map on the wire. */ accountTotals: VatCheckAccountTotals } /** * Agent-facing VAT report. Thin wrapper over {@link computeVatReportWithRutor} * so callers that only need the report keep the old signature. */ export async function computeVatReport( args: Record, companyId: string, supabase: SupabaseClient ): Promise { const { report } = await computeVatReportWithRutor(args, companyId, supabase) return report } export async function computeVatReportWithRutor( args: Record, companyId: string, supabase: SupabaseClient ): Promise { const periodType = args.period_type as string const year = Number(args.year) const period = Number(args.period) if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { throw new Error('period_type must be: monthly, quarterly, yearly') } if (!year || year < 2000 || year > 2100) throw new Error('year must be between 2000 and 2100') if (periodType === 'monthly' && (period < 1 || period > 12)) throw new Error('period must be 1-12 for monthly') if (periodType === 'quarterly' && (period < 1 || period > 4)) throw new Error('period must be 1-4 for quarterly') let startDate: string let endDate: string if (periodType === 'monthly') { startDate = `${year}-${String(period).padStart(2, '0')}-01` const lastDay = new Date(year, period, 0).getDate() endDate = `${year}-${String(period).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` } else if (periodType === 'quarterly') { const startMonth = (period - 1) * 3 + 1 const endMonth = period * 3 startDate = `${year}-${String(startMonth).padStart(2, '0')}-01` const lastDay = new Date(year, endMonth, 0).getDate() endDate = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` } else { startDate = `${year}-01-01` endDate = `${year}-12-31` } // Two-step fetch (lib/bookkeeping/entry-lines.ts) rather than a // `journal_entries!inner` embed: PostgREST compiles that embed into a // correlated LATERAL join that walks every tenant's journal_entry_lines. // Both steps paginate, so a yearly (or busy quarterly) VAT period with // >1000 entry lines is no longer silently truncated at PostgREST's // 1000-row default. // The helper reattaches the parent entry as an OBJECT under // `journal_entries`, so the old "embed may be an object or an array" shape // guard is gone with the embed. const lines = await fetchEntryLines<{ journal_entry_id: string account_number: string debit_amount: number credit_amount: number journal_entries?: { source_type: string | null } }>({ supabase, entryColumns: 'entry_date, status, user_id, source_type', lineColumns: 'journal_entry_id, account_number, debit_amount, credit_amount', filterEntries: (q: EntryLinesQuery) => q .eq('company_id', companyId) .in('status', ['posted', 'reversed']) // Momsredovisning entries (the settlement verifikat clearing 26xx to // 2650/1650) would zero the rutor once booked; exclude them so this // report matches lib/reports/vat-declaration.ts (fetchVatAccountTotals). .neq('source_type', 'vat_settlement') .gte('entry_date', startDate) .lte('entry_date', endDate), }) // Settlements booked WITHOUT the vat_settlement tag (manual momsomföring, // SIE-imported settlements, stornos of a settlement) are excluded by shape, // mirroring fetchVatAccountTotals (#984): an entry touching both a // declaration account (ACCOUNT_RUTA) and a settlement net account // (2650/1650) is a momsredovisning, not VAT-bearing activity. Opening // balances are exempt: carried-in 26xx balances are unsettled VAT that // belongs in the next declaration. const declarationEntryIds = new Set() const netEntryIds = new Set() for (const line of lines) { if (ACCOUNT_RUTA[line.account_number]) declarationEntryIds.add(line.journal_entry_id) else if (VAT_SETTLEMENT_NET_ACCOUNTS.includes(line.account_number)) { netEntryIds.add(line.journal_entry_id) } } const settlementShapedIds = new Set() for (const line of lines) { const id = line.journal_entry_id if (!declarationEntryIds.has(id) || !netEntryIds.has(id)) continue const entry = line.journal_entries if (!entry || entry.source_type === 'opening_balance') continue settlementShapedIds.add(id) } const accountTotals = new Map() for (const line of lines) { if (settlementShapedIds.has(line.journal_entry_id)) continue const acc = line.account_number const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 } existing.debit += Number(line.debit_amount) || 0 existing.credit += Number(line.credit_amount) || 0 accountTotals.set(acc, existing) } function creditBalance(acc: string): number { const t = accountTotals.get(acc) return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0 } function debitBalance(acc: string): number { const t = accountTotals.get(acc) return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0 } // The company's own momspliktiga intäktskonton join the hand-maintained list. // Deduped: an account can appear in both (e.g. 3041 with a moms-sats set), // and counting it twice would inflate ruta 05. const dynamicRuta05 = await fetchDynamicRuta05Accounts(supabase, companyId) const ruta05Accounts = [...new Set([...RUTA_05_ACCOUNTS, ...dynamicRuta05.accounts])] const ruta05 = ruta05Accounts.reduce((sum, acc) => sum + creditBalance(acc), 0) const ruta10 = creditBalance('2611') const ruta11 = creditBalance('2621') const ruta12 = creditBalance('2631') const ruta30 = creditBalance('2614') const ruta31 = creditBalance('2624') const ruta32 = creditBalance('2634') const ruta35 = creditBalance('3108') // EU intra-community goods supplies (momsfri leverans till EU) const ruta39 = creditBalance('3308') const ruta40 = creditBalance('3305') // Import VAT (since 2015 declared via momsdeklaration, not Tullverket): the // importer books output VAT to 2615/2625/2635 (ruta 60/61/62) and the // matching deductible input to 2645 (rolls into ruta 48 below). const ruta60 = creditBalance('2615') const ruta61 = creditBalance('2625') const ruta62 = creditBalance('2635') const calculatedInput2645 = debitBalance('2645') const calculatedInput2647 = debitBalance('2647') const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647 const ruta49 = Math.round( (ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 + ruta60 + ruta61 + ruta62 - ruta48) * 100 ) / 100 const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December'] let periodLabel: string if (periodType === 'monthly') periodLabel = `${monthNames[period - 1]} ${year}` else if (periodType === 'quarterly') periodLabel = `Q${period} ${year}` else periodLabel = `${year}` // Pre-filing warnings: surface common compliance footguns. // // The matching input for reverse-charge output (2614/2624/2634) lands on // 2645 (EU acquisitions) or 2647 (domestic reverse charge per ML 16:13, // byggtjänster, electronics > 100k SEK, etc.). Either is a valid mirror; // the warning must fire only when *both* are zero. const warnings: string[] = [] const totalReverseChargeOutput = ruta30 + ruta31 + ruta32 const totalReverseChargeInput = calculatedInput2645 + calculatedInput2647 if (totalReverseChargeOutput > 0 && totalReverseChargeInput === 0) { warnings.push( 'Omvänd betalningsskyldighet: utgående moms har bokförts (rutor 30/31/32) men ingen ' + 'beräknad ingående moms (varken 2645 EU eller 2647 inhemsk). Kontrollera att den ' + 'motsvarande ingående bokningen finns: båda sidor krävs enligt ML 2023:200.' ) } const report: VatReportResult = { period: { type: periodType, year, period, start: startDate, end: endDate }, period_label: periodLabel, rutor: { ruta05: Math.abs(ruta05), ruta10: Math.abs(ruta10), ruta11: Math.abs(ruta11), ruta12: Math.abs(ruta12), ruta30: Math.abs(ruta30), ruta31: Math.abs(ruta31), ruta32: Math.abs(ruta32), ruta35: Math.abs(ruta35), ruta39: Math.abs(ruta39), ruta40: Math.abs(ruta40), ruta48: Math.abs(ruta48), ruta49, ruta60: Math.abs(ruta60), ruta61: Math.abs(ruta61), ruta62: Math.abs(ruta62), }, summary: ruta49 > 0 ? `Moms att betala: ${Math.abs(ruta49).toFixed(2)} kr` : ruta49 < 0 ? `Moms att få tillbaka: ${Math.abs(ruta49).toFixed(2)} kr` : 'Noll i moms', warnings, } // Same `accountTotals` the report is built from, projected through core's // ACCOUNT_RUTA map so the completeness checks see the full declaration // (incl. rutor 20-24 and 50) instead of the trimmed report view. return { report, declarationRutor: rutorFromTotals(accountTotals, dynamicRuta05.accounts), accountTotals, } } /** * Momsdeklaration completeness pre-flight, shared by gnubok_vat_close_check * and gnubok_vat_declaration_validate. * * Runs core's `runVatDeclarationChecks` (period aggregates, proportional) and * folds in the per-verifikat FK004 scan through the SAME gate helper the web * UI's "Kontroll av underlaget" banner and its Skicka button read * (lib/reports/vat-filing-gate.ts). One check list, one verdict: the MCP * surface can no longer give a green light the UI would refuse. * * The per-verifikat scan is allowed to degrade: a failure becomes * `{ status: 'unavailable' }`, which the gate turns into an explicit WARNING * finding rather than silence, because an empty list reads as "no problems" * and that claim is not earned when the scan never answered. * * `accountTotals` is the per-account debit/credit aggregate the rutor were * projected from. Passing it switches RC_INPUT_VAT_MISMATCH from the ruta 48 * aggregate to the reverse-charge input accounts (2645/2647). Both call sites * have it in hand, so both pass it; the parameter stays optional only because * the check itself degrades gracefully without it. */ async function runVatCompletenessChecks( supabase: SupabaseClient, companyId: string, rutor: VatDeclarationRutor, periodType: VatPeriodType, year: number, period: number, accountTotals?: VatCheckAccountTotals, ): Promise { let scan: RcBasisGapScan try { const gaps = await findRcBasisGaps(supabase, companyId, periodType, year, period) scan = { status: 'scanned', gapCount: gaps.length } } catch { scan = { status: 'unavailable' } } return withRcBasisGapFindings(runVatDeclarationChecks(rutor, accountTotals), scan) } /** Wire shape for a completeness finding on the MCP surface. */ interface VatCompletenessFinding { code: VatDeclarationCheck['code'] status: VatDeclarationCheckStatus message: string rutor: string[] } /** * Serialize findings for an agent. Unlike the web UI (which deliberately hides * the rule ids as visual noise, DECISIONS 2026-07-24), the machine surface * carries `code`: an agent needs a stable key to branch on, not prose. */ function toCompletenessFindings(checks: VatDeclarationCheck[]): VatCompletenessFinding[] { return checks.map((c) => ({ code: c.code, status: c.status, message: c.message, rutor: (c.rutor ?? []) as string[], })) } // ── VAT close check (composes VAT report + blocker scans + sanity ratios) ── // // Intent-shaped tool: answers "can I close VAT for this period?" in one call. // Replaces the 5-7 chained tool calls (vat_report + uncategorized + supplier // invoices + reconciliation + voucher gaps + prior-period compare) the agent // would otherwise need to assemble the same answer. interface VatCloseBlocker { kind: | 'uncategorized_transactions' | 'unapproved_supplier_invoices' | 'bank_unreconciled' | 'missing_high_value_receipts' | 'reverse_charge_input_missing' | 'declaration_incomplete' severity: 'high' | 'medium' | 'low' count: number message: string hint: string /** * Stable rule id when this blocker comes from the shared momsdeklaration * completeness checks (lib/reports/vat-declaration-checks.ts), so an agent * can branch on the rule rather than parse the Swedish message. */ check_code?: VatDeclarationCheck['code'] } /** * Completeness codes that describe the omvänd-skattskyldighet pair. They keep * the pre-existing `reverse_charge_input_missing` blocker kind so clients * already switching on it do not lose the case they were watching for. */ const RC_COMPLETENESS_CODES = new Set([ 'RC_BASIS_MISSING', 'RC_OUTPUT_MISSING', 'RC_INPUT_VAT_MISMATCH', ]) interface VatCloseSanityAnomaly { kind: 'output_vat_ratio_drift' | 'input_vat_ratio_drift' | 'revenue_drop' | 'revenue_spike' rate?: '25' | '12' | '6' current: number previous: number delta_pct: number message: string } interface VatCloseCheckResult { period: VatReportResult['period'] period_label: string rutor: VatReportResult['rutor'] payment: { net_due: number direction: 'pay' | 'refund' | 'zero' deadline: string | null deadline_label: string | null moms_period: 'monthly' | 'quarterly' | 'yearly' | null } blockers: VatCloseBlocker[] /** * The momsdeklaration completeness findings behind the * declaration_incomplete / reverse_charge_input_missing blockers, verbatim * from the shared checks. Empty means the declaration itself looks complete; * it says nothing about the other blockers. */ declaration_checks: VatCompletenessFinding[] sanity: { anomalies: VatCloseSanityAnomaly[] ratios: { output_vat_ratio_25: number // ruta10 / domestic 25% revenue output_vat_ratio_12: number output_vat_ratio_6: number previous_period_compared: boolean } } ready_to_close: boolean summary: string } /** Compute the Skatteverket momsdeklaration deadline for a period. * - monthly: due on the 12th of (period-end-month + 1) * - quarterly: 26th of the month after quarter-end (Q4 → 26 Jan next year) * - yearly: 26 Feb of next year */ export function computeMomsDeadline( periodType: 'monthly' | 'quarterly' | 'yearly', year: number, period: number ): { date: string; label: string } | null { if (periodType === 'monthly') { // period 1-12; deadline = 12th of next month const deadlineMonth = period === 12 ? 1 : period + 1 const deadlineYear = period === 12 ? year + 1 : year return { date: `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-12`, label: `12 ${monthName(deadlineMonth)} ${deadlineYear}`, } } if (periodType === 'quarterly') { // Q1→26 apr, Q2→26 jul, Q3→26 okt, Q4→26 jan next year const monthByQuarter: Record = { 1: { m: 4, yOffset: 0 }, 2: { m: 7, yOffset: 0 }, 3: { m: 10, yOffset: 0 }, 4: { m: 1, yOffset: 1 }, } const cfg = monthByQuarter[period] if (!cfg) return null return { date: `${year + cfg.yOffset}-${String(cfg.m).padStart(2, '0')}-26`, label: `26 ${monthName(cfg.m)} ${year + cfg.yOffset}`, } } if (periodType === 'yearly') { return { date: `${year + 1}-02-26`, label: `26 februari ${year + 1}`, } } return null } function monthName(m: number): string { return ['januari', 'februari', 'mars', 'april', 'maj', 'juni', 'juli', 'augusti', 'september', 'oktober', 'november', 'december'][m - 1] ?? '' } // ── agent_memory dedup helpers ─────────────────────────────── // Cheap, embedding-free near-duplicate detection for gnubok_remember_fact. // Lowercase, strip punctuation, drop very short / stop-ish words, and // compare two memories by Jaccard similarity over their word sets. Good // enough to catch the agent re-remembering the same fact in slightly // different words; not a substitute for semantic embeddings, but zero-cost. const MEMORY_DEDUP_STOPWORDS = new Set([ 'och', 'att', 'det', 'som', 'en', 'ett', 'är', 'för', 'med', 'på', 'av', 'till', 'den', 'de', 'i', 'om', 'har', 'var', 'kan', 'ska', 'samt', 'the', 'a', 'an', 'is', 'are', 'for', 'with', 'of', 'to', 'and', 'in', ]) function tokenizeForDedup(text: string): Set { const tokens = text .toLowerCase() .replace(/[^\p{L}\p{N}\s]/gu, ' ') .split(/\s+/) .filter((t) => t.length >= 3 && !MEMORY_DEDUP_STOPWORDS.has(t)) return new Set(tokens) } function jaccardSimilarity(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0 let intersection = 0 for (const t of a) if (b.has(t)) intersection++ const union = a.size + b.size - intersection return union === 0 ? 0 : intersection / union } /** * Gross floor for the missing-underlag blocker. ML 17 kap 26-28 § (förenklad * faktura) expresses 4 000 kr inclusive of moms, so the comparison is against * the gross (sum of debits, equal to sum of credits in a balanced entry). For * EU acquisitions and domestic reverse-charge buyer entries the calculated VAT * lines inflate that sum, which can pull a sub-threshold purchase above 4 000: * a false positive in favour of asking for the underlag, the safe direction. */ const MISSING_UNDERLAG_MIN_GROSS_SEK = 4000 /** ISO date one day after `isoDate`, in UTC so no local offset can shift it. */ function dayAfter(isoDate: string): string { const d = new Date(`${isoDate}T00:00:00Z`) d.setUTCDate(d.getUTCDate() + 1) return d.toISOString().slice(0, 10) } /** One `verifikat_without_documents` page-of-one, used only for its total. */ async function totalMissingUnderlagSince( supabase: SupabaseClient, companyId: string, since: string ): Promise { const { data, error } = await supabase.rpc('verifikat_without_documents', { p_company_id: companyId, p_since: since, p_min_amount: MISSING_UNDERLAG_MIN_GROSS_SEK, // p_limit only sizes the page; total_count is computed over the FULL // filtered set in an independent CTE, so 1 is the cheapest valid size. p_limit: 1, p_offset: 0, }) if (error) throw new Error(`verifikat_without_documents failed: ${error.message}`) const result = data as { ok?: boolean; code?: string; total_count?: number } | null if (!result?.ok) { throw new Error(`verifikat_without_documents failed: ${result?.code ?? 'unknown error'}`) } return result.total_count ?? 0 } /** * Posted verifikat dated within [start, end] that genuinely lack an underlag * and whose gross reaches MISSING_UNDERLAG_MIN_GROSS_SEK. * * BFL 5 kap 6-7 §: every affärshändelse needs a verifikation, and the * verifikation must reference its underlag. This delegates to the * `verifikat_without_documents` RPC, the SINGLE owner of that predicate: the * same SQL behind the web worklist badge (countVerifikatMissingDocument) and * behind gnubok_list_verifikat_without_documents. It carries three things a * hand-rolled scan here kept getting wrong: * * 1. the needs-doc source types (mirrors NEEDS_DOC_SOURCE_TYPES, * lib/worklist/categories.ts, pinned by * tests/pg/document-surfaces-unification.pg.test.ts). The local list read * 'supplier_invoice' and 'receipt', which are not members of the * journal_entries.source_type CHECK at all: PostgREST matched zero rows, * so supplier-invoice verifikat NEVER surfaced here and the momsperiod * got a clean bill of health on exactly the entry types most likely to be * missing their underlag; * 2. is_current_version, so a superseded document version does not silence * the warning, and journal_entry_no_doc_required, so an explicit user * waiver does; * 3. BFL 5 kap 7 § hänvisning till underlag: a payment verifikat whose * supplier invoice carries an anchored document is covered by that * document even though the doc row hangs on the registration verifikat. * Without this, adding supplier_invoice_paid to the list would flag every * paid supplier invoice in the period (the 2026-07-24 support case). * * The RPC takes `since` and no upper bound, so the in-period count is the * difference between two filter-respecting totals. Both calls run the same * predicate, so the subtraction is exact rather than an estimate. */ async function countMissingUnderlagInPeriod( supabase: SupabaseClient, companyId: string, start: string, end: string ): Promise { const [fromStart, afterEnd] = await Promise.all([ totalMissingUnderlagSince(supabase, companyId, start), totalMissingUnderlagSince(supabase, companyId, dayAfter(end)), ]) return Math.max(0, fromStart - afterEnd) } /** * Resolve the cash-account identity before comparing its bank feed with the * ledger. The cashAccountId is what prevents another same-currency account * from being included in the transaction total. * * The lookup itself lives in lib/reconciliation/cash-account-scope.ts so the * bokslut readiness aggregator (core code, which cannot import from * @/extensions/) resolves the scope the exact same way. It keeps the fail-closed * contract this function introduced in #1295: a cash_accounts lookup error * throws rather than degrading into the unscoped currency-only path. * * Pass accountNumber only when the CALLER named an account; leaving it * undefined means "the company's bank account", which additionally falls back * to the primary cash account for companies that have no 1930 row at all. */ async function getScopedReconciliationStatus( supabase: SupabaseClient, companyId: string, dateFrom: string | undefined, dateTo: string | undefined, accountNumber?: string, ) { const scope = await resolveCashAccountScope(supabase, companyId, accountNumber) if (!scope.found && accountNumber !== undefined && accountNumber !== '1930') { throw new Error(`Okänt kassakonto ${accountNumber} för det här företaget`) } const status = await getReconciliationStatus( supabase, companyId, dateFrom, dateTo, scope.accountNumber, scope.currency, scope.cashAccountId, scope.includeUnassigned, ) return { status, scope } } export async function computeVatCloseCheck( args: Record, companyId: string, supabase: SupabaseClient ): Promise { // 1) VAT report (validates inputs + gives us figures + period dates). The // full SKV 4700 projection rides along for the completeness checks in // step 4b: they need rutor 20-24 and 50, which the report view omits, plus // the per-account totals so the RC input comparison reads 2645/2647 // instead of the ruta 48 aggregate. const { report: vatReport, declarationRutor, accountTotals } = await computeVatReportWithRutor(args, companyId, supabase) const { start, end, type: periodType, year, period } = vatReport.period // 2) Company settings: moms_period drives deadline labelling const { data: settings } = await supabase .from('company_settings') .select('moms_period') .eq('company_id', companyId) .single() const momsPeriod = (settings?.moms_period as 'monthly' | 'quarterly' | 'yearly' | null) ?? null // 3) Deadline: based on the *requested* period type, not company setting, // so the model gets the right deadline even when querying ad-hoc periods. const deadline = computeMomsDeadline( periodType as 'monthly' | 'quarterly' | 'yearly', Number(year), Number(period) ) // 4) Blocker scans: run in parallel const [uncategorizedRes, unapprovedRes, recon, missingUnderlag] = await Promise.all([ supabase .from('transactions') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .gte('date', start).lte('date', end) .is('journal_entry_id', null), supabase .from('supplier_invoices') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .eq('status', 'registered') .gte('invoice_date', start).lte('invoice_date', end), // No account_number argument: the close check wants "the company's bank // account", so the scope resolver may land on the primary cash account for // a company that has no 1930 row. getScopedReconciliationStatus(supabase, companyId, start, end), // Verifikat in the period that genuinely lack an underlag (BFL 5 kap // 6-7 §), counted over the SHARED SQL predicate. Never re-derive this // locally: countMissingUnderlagInPeriod documents what the hand-rolled // scan that used to sit here got wrong. countMissingUnderlagInPeriod(supabase, companyId, start, end), ]) const blockers: VatCloseBlocker[] = [] const uncategorizedCount = uncategorizedRes.count ?? 0 if (uncategorizedCount > 0) { blockers.push({ kind: 'uncategorized_transactions', severity: 'high', count: uncategorizedCount, message: `${uncategorizedCount} okategoriserade banktransaktioner i perioden`, hint: 'Kategorisera via gnubok_categorize_transaction eller kör gnubok_auto_match_period.', }) } const unapprovedCount = unapprovedRes.count ?? 0 if (unapprovedCount > 0) { blockers.push({ kind: 'unapproved_supplier_invoices', severity: 'high', count: unapprovedCount, message: `${unapprovedCount} oattesterade leverantörsfakturor i perioden`, hint: 'Attestera via gnubok_approve_supplier_invoice: ingående moms (ruta 48) påverkas.', }) } const reconRes = recon.status if (!reconRes.is_reconciled) { blockers.push({ kind: 'bank_unreconciled', severity: Math.abs(reconRes.difference) > 100 ? 'high' : 'medium', count: reconRes.unmatched_transaction_count + reconRes.unmatched_gl_line_count, // The account is named from the RESOLVED scope, not hard-coded: for a // company with no 1930 row this check now reconciles its primary cash // account, and a message pointing at 1930 would send the user to an // account with no lines on it. message: `Bankavstämning visar differens ${reconRes.difference.toFixed(2)} kr (${reconRes.unmatched_transaction_count} omatchade banktransaktioner, ${reconRes.unmatched_gl_line_count} omatchade huvudbokslinjer på ${recon.scope.accountNumber})`, hint: 'Granska via gnubok_get_reconciliation_status och matcha: moms beräknas från huvudboken så differenser döljer fel.', }) } if (missingUnderlag > 0) { blockers.push({ kind: 'missing_high_value_receipts', severity: 'medium', count: missingUnderlag, message: `${missingUnderlag} verifikat över ${MISSING_UNDERLAG_MIN_GROSS_SEK} kr saknar underlag`, hint: `BFL 5 kap 6-7 §: varje affärshändelse måste ha en verifikation med hänvisning till sitt underlag. Lista dem med gnubok_list_verifikat_without_documents (since=${start}, min_amount=${MISSING_UNDERLAG_MIN_GROSS_SEK}) och para ihop via gnubok_list_unmatched_documents.`, }) } // 4b) Is the DECLARATION itself complete? Everything above is about the // bookkeeping around it; this is about the momsdeklaration. // // Until 2026-07 this was a single hand-rolled mirror, // `acquisitionAndImportBase > 0 && ruta48 === 0`, and ruta 48 aggregates // 2641/2642/2645/2646/2647/2649: ONE ordinary domestic receipt in the // period made it unreachable, so a declaration with omvänd // skattskyldighet booked on one side only sailed through as "Klart för // stängning". There was no basbelopp check (rutor 20-24 vs 30-32) at all, // which is the FK004 case Skatteverket rejects. // // Now the shared core checks run instead, over the full SKV 4700 // projection, so the MCP verdict is the SAME verdict the web UI's // "Kontroll av underlaget" gate gives. Never re-derive these locally. const declarationChecks = await runVatCompletenessChecks( supabase, companyId, declarationRutor, periodType as VatPeriodType, Number(year), Number(period), accountTotals, ) // Zero deductible input VAT against self-assessed utgående moms is // unambiguous: rutor 30-32 (omvänd skattskyldighet, 2614/2624/2634) and // rutor 60-62 (import since 2015, 2615/2625/2635) each require the matching // ingående moms on 2645/2647 (ML 2023:200); with ruta 48 at zero there is no // partial-deduction story that explains it. // // The shared RC_INPUT_VAT_MISMATCH now isolates the RC share exactly (it reads // 2645/2647, see the accountTotals argument above) and still stays a WARNING, // deliberately: limited avdragsrätt (blandad verksamhet, ML 13 kap 18/24-25 §§) // makes a shortfall legally correct for some filers, and no SKV gateway rule // rejects it. Both halves of this escalation survive that sharpening: // - the RC half turns the warning into a blocker in the one case where no // deduction story exists at all (ruta 48 itself is zero); // - the IMPORT half (rutor 60-62 against ruta 48, below) is coverage the // shared checks still do not have: they compare import output only against // the tullvärdesunderlag in ruta 50, never against the input side. const eps = 0.5 const rcOutput = declarationRutor.ruta30 + declarationRutor.ruta31 + declarationRutor.ruta32 const selfAssessedOutput = rcOutput + declarationRutor.ruta60 + declarationRutor.ruta61 + declarationRutor.ruta62 const noInputVatAtAll = selfAssessedOutput > eps && declarationRutor.ruta48 <= eps for (const check of declarationChecks) { const escalated = check.status === 'ERROR' || (check.code === 'RC_INPUT_VAT_MISMATCH' && noInputVatAtAll) blockers.push({ kind: RC_COMPLETENESS_CODES.has(check.code) ? 'reverse_charge_input_missing' : 'declaration_incomplete', severity: escalated ? 'high' : 'medium', count: 1, message: check.message, hint: check.rutor?.length ? `Granska ${check.rutor.join(', ')} i huvudboken innan inlämning (gnubok_get_general_ledger).` : 'Granska underlaget i huvudboken innan inlämning (gnubok_get_general_ledger).', check_code: check.code, }) } // Import-only variant of the same defect: no RC output means no // RC_INPUT_VAT_MISMATCH finding exists to escalate above. if (noInputVatAtAll && rcOutput <= eps) { blockers.push({ kind: 'reverse_charge_input_missing', severity: 'high', count: 1, message: 'Import: utgående importmoms är bokförd (ruta 60/61/62) men ingen avdragsgill ingående moms alls (ruta 48 är noll)', hint: 'ML 2023:200: importören redovisar både beräknad utgående moms (2615/2625/2635) och avdragsgill ingående moms (2645).', }) } // 5) Sanity ratios: current period output VAT to revenue per rate, vs prior period const ratios = { output_vat_ratio_25: vatReport.rutor.ruta05 > 0 ? Math.round((vatReport.rutor.ruta10 / vatReport.rutor.ruta05) * 10000) / 100 : 0, output_vat_ratio_12: 0, // no per-rate revenue split available from VAT report output_vat_ratio_6: 0, previous_period_compared: false, } const anomalies: VatCloseSanityAnomaly[] = [] // Compare to previous same-length period const prevArgs = previousPeriodArgs(periodType as 'monthly' | 'quarterly' | 'yearly', Number(year), Number(period)) if (prevArgs) { try { const prev = await computeVatReport(prevArgs, companyId, supabase) ratios.previous_period_compared = true // Output VAT ratio 25% drift if (vatReport.rutor.ruta05 > 0 && prev.rutor.ruta05 > 0) { const cur = vatReport.rutor.ruta10 / vatReport.rutor.ruta05 const prv = prev.rutor.ruta10 / prev.rutor.ruta05 if (prv > 0) { const deltaPct = Math.round(((cur - prv) / prv) * 10000) / 100 if (Math.abs(deltaPct) > 20) { anomalies.push({ kind: 'output_vat_ratio_drift', rate: '25', current: Math.round(cur * 10000) / 100, previous: Math.round(prv * 10000) / 100, delta_pct: deltaPct, message: `Utgående moms 25% / försäljning ändrades ${deltaPct > 0 ? '+' : ''}${deltaPct}% jämfört med föregående period: kontrollera momssatser`, }) } } } // Revenue spike/drop if (prev.rutor.ruta05 > 0) { const revDelta = Math.round(((vatReport.rutor.ruta05 - prev.rutor.ruta05) / prev.rutor.ruta05) * 10000) / 100 if (revDelta < -50) { anomalies.push({ kind: 'revenue_drop', current: vatReport.rutor.ruta05, previous: prev.rutor.ruta05, delta_pct: revDelta, message: `Försäljning föll ${revDelta}%: bekräfta att alla fakturor är bokförda`, }) } else if (revDelta > 200) { anomalies.push({ kind: 'revenue_spike', current: vatReport.rutor.ruta05, previous: prev.rutor.ruta05, delta_pct: revDelta, message: `Försäljning steg ${revDelta}%: kontrollera att inget bokats två gånger`, }) } } } catch { // Previous period unavailable: skip comparison silently } } const highBlockers = blockers.filter((b) => b.severity === 'high').length // Two gates, one verdict. `isFilingBlocked` over the SAME check array the web // UI gates "Skicka till Skatteverket" on is authoritative for the declaration // itself; the blocker scan covers the bookkeeping around it. An ERROR finding // is already a high blocker, so this is belt and braces on purpose: the // readiness answer must never come from a narrower source than the UI's. const declarationBlocked = isFilingBlocked(declarationChecks) const readyToClose = highBlockers === 0 && !declarationBlocked const netDue = vatReport.rutor.ruta49 const direction: 'pay' | 'refund' | 'zero' = netDue > 0 ? 'pay' : netDue < 0 ? 'refund' : 'zero' const declarationErrors = declarationChecks.filter((c) => c.status === 'ERROR').length let summary: string if (readyToClose && anomalies.length === 0) { summary = `Klart för stängning. ${direction === 'pay' ? `Moms att betala: ${netDue.toFixed(2)} kr` : direction === 'refund' ? `Moms att få tillbaka: ${Math.abs(netDue).toFixed(2)} kr` : 'Noll i moms'}.${deadline ? ` Inlämning senast ${deadline.label}.` : ''}` } else if (readyToClose) { summary = `Klart för stängning men ${anomalies.length} avvikelse(r) att granska.` } else if (declarationBlocked) { summary = `Inte klart: deklarationsunderlaget är ofullständigt (${declarationErrors} fel), ${highBlockers} kritiska blockerare totalt.` } else { summary = `Inte klart: ${highBlockers} kritiska blockerare.` } return { period: vatReport.period, period_label: vatReport.period_label, rutor: vatReport.rutor, payment: { net_due: netDue, direction, deadline: deadline?.date ?? null, deadline_label: deadline?.label ?? null, moms_period: momsPeriod, }, blockers, declaration_checks: toCompletenessFindings(declarationChecks), sanity: { anomalies, ratios }, ready_to_close: readyToClose, summary, } } function previousPeriodArgs( periodType: 'monthly' | 'quarterly' | 'yearly', year: number, period: number ): { period_type: string; year: number; period: number } | null { if (periodType === 'monthly') { if (period === 1) return { period_type: 'monthly', year: year - 1, period: 12 } return { period_type: 'monthly', year, period: period - 1 } } if (periodType === 'quarterly') { if (period === 1) return { period_type: 'quarterly', year: year - 1, period: 4 } return { period_type: 'quarterly', year, period: period - 1 } } if (periodType === 'yearly') { return { period_type: 'yearly', year: year - 1, period: 1 } } return null } // Shared by the report tools' optional `dimensions` filter arg: parse the raw // bag, then resolve value NAMES → registry codes in one pass (resolve-don't- // select: the exact contract gnubok_create_voucher uses, incl. free-text // passthrough while dimensions_enabled is off). A DimensionResolutionError // propagates to the caller with ranked candidates. The resolved bag is echoed // back as `dimension_filter` so the agent can verify what a name attached to. async function resolveReportDimensionFilter( supabase: SupabaseClient, companyId: string, raw: unknown, ): Promise<{ filter?: Record; resolutions: DimensionResolution[] }> { if (!raw || typeof raw !== 'object' || Object.keys(raw as object).length === 0) { return { resolutions: [] } } const parsed = parseDimensionsArg(raw, 'dimensions') const { bags, resolutions } = await resolveDimensionBags(supabase, companyId, [parsed]) return { filter: bags[0], resolutions } } // Input-schema fragment for that arg: identical shape on trial balance, // income statement, and general ledger. const REPORT_DIMENSIONS_FILTER_SCHEMA = { type: 'object', additionalProperties: { type: 'string' }, description: 'Filter: SIE dim no → value (code OR name, resolved server-side), e.g. {"6":"P001"}. P&L view only: opening balances are excluded when set.', } as const // Output-schema fragments for the echo fields (never in `required`). const DIMENSION_FILTER_OUTPUT_PROPS = { dimension_filter: { type: 'object', additionalProperties: { type: 'string' }, description: 'Echo of the applied filter, resolved to registry codes.', }, dimension_resolutions: { type: 'array', items: { type: 'object' }, description: 'Non-exact name→code resolution echoes (resolve-don\'t-select).', }, } as const let canonicalToolNamesCache: ReadonlySet | undefined function getCanonicalToolNames(): ReadonlySet { canonicalToolNamesCache ??= new Set(tools.map((tool) => tool.name)) return canonicalToolNamesCache } function projectMcpPayload(value: T, namespace: McpToolNamespace): T { return projectToolReferences(value, namespace, getCanonicalToolNames()) } // ── Tools ──────────────────────────────────────────────────── export const tools: McpTool[] = [ { name: 'gnubok_search_tools', title: 'Search MCP Tools', description: 'Search available tools by keyword and choose the returned schema detail level.', inputSchema: { type: 'object', additionalProperties: false, properties: { query: { type: 'string', description: 'Keywords matched against tool names and descriptions. Empty returns all tools.' }, detail: { type: 'string', enum: ['name', 'summary', 'full'], description: 'Detail level. name: just names. summary: name + description + scope (default). full: complete schema including inputSchema and outputSchema.' }, scope: { type: 'string', description: 'Optional filter: only tools requiring this API key scope (e.g. "invoices:write").' }, limit: { type: 'number', description: 'Max results, 1-50 (default 20).' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { tools: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, total_matched: { type: 'number' }, detail: { type: 'string' }, }, required: ['tools', 'count', 'total_matched', 'detail'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, _companyId, _userId, _supabase, _actor) { const namespace: McpToolNamespace = args.__toolNamespace === 'accounted' ? 'accounted' : 'gnubok' const query = canonicalizeToolReferencesInText( ((args.query as string) || '').toLowerCase().trim() ) const detail = ((args.detail as string) || 'summary') as 'name' | 'summary' | 'full' const scopeFilter = args.scope as string | undefined const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) // Filter results to tools the caller is actually authorized to invoke. // // The dispatcher injects __keyScopes when it routes to gnubok_search_tools. // If the marker is missing (refactor regression, direct execute() invocation // outside the dispatcher, etc.), FAIL CLOSED: return only unscoped tools // rather than leaking the full inventory. The marker presence is also part // of the contract: an explicitly-empty array means "no scopes granted", // which still hides scoped tools. const rawKeyScopes = (args as Record).__keyScopes const callerScopes: string[] = Array.isArray(rawKeyScopes) ? (rawKeyScopes as string[]) : [] const scopesInjected = Array.isArray(rawKeyScopes) let candidates = tools.filter((t) => { const required = TOOL_SCOPE_MAP[t.name] if (required) { // Scoped tool: visible only if scopes were injected AND the caller has it. if (!scopesInjected) return false if (!callerScopes.includes(required)) return false } if (scopeFilter && required !== scopeFilter) return false return true }) if (query) { // Match: every whitespace-separated term must appear in name or description // (for a single-word query this is identical to a literal substring match). // Rank by relevance so the most on-point tool comes first instead of // whichever happens to be defined earliest: exact-ish name match > full // query as a name substring > per-term name hits > description hits. Ties // fall back to definition order (stable). const terms = query.split(/\s+/).filter(Boolean) const ranked = candidates .map((t, idx) => { const name = t.name.toLowerCase() const desc = t.description.toLowerCase() const hay = `${name} ${desc}` if (!terms.every((term) => hay.includes(term))) return null let score = 0 if (name === query || name === `gnubok_${query}` || name.endsWith(`_${query}`)) score += 100 if (name.includes(query)) score += 40 for (const term of terms) { if (name.includes(term)) score += 10 if (desc.includes(term)) score += 1 } return { t, score, idx } }) .filter((x): x is { t: McpTool; score: number; idx: number } => x !== null) .sort((a, b) => b.score - a.score || a.idx - b.idx) candidates = ranked.map((x) => x.t) } const totalMatched = candidates.length const sliced = candidates.slice(0, limit) const projected = sliced.map((t) => { const requiredScope = TOOL_SCOPE_MAP[t.name] ?? null if (detail === 'name') { return { name: toPublicToolName(t.name, namespace), scope: requiredScope } } if (detail === 'full') { const meta = projectMcpPayload( { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }, namespace ) return projectMcpPayload( { name: toPublicToolName(t.name, namespace), description: t.description, scope: requiredScope, inputSchema: projectToolInputSchema(t), ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), annotations: t.annotations, ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), }, namespace ) } // summary (default) return projectMcpPayload( { name: toPublicToolName(t.name, namespace), description: t.description, scope: requiredScope, }, namespace ) }) return { tools: projected, count: projected.length, total_matched: totalMatched, detail, } }, }, { name: 'gnubok_list_companies', title: 'List Companies', description: 'List every non-archived company this API-key user can access. Use company_id from this result on other tools; omit it there to use the API key default.', inputSchema: { type: 'object', additionalProperties: false, properties: {}, }, outputSchema: { type: 'object', additionalProperties: false, properties: { companies: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { company_id: { type: 'string' }, name: { type: 'string' }, org_number: { type: ['string', 'null'] }, entity_type: { type: ['string', 'null'] }, role: { type: 'string', enum: ['owner', 'admin', 'member', 'viewer'] }, is_default: { type: 'boolean' }, }, required: ['company_id', 'name', 'org_number', 'entity_type', 'role', 'is_default'], }, }, count: { type: 'number' }, default_company_id: { type: ['string', 'null'] }, }, required: ['companies', 'count', 'default_company_id'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, defaultCompanyId, userId, supabase) { type CompanyRow = { id: string name: string org_number: string | null entity_type: string | null archived_at: string | null } type MembershipRow = { company_id: string role: 'owner' | 'admin' | 'member' | 'viewer' companies: CompanyRow | CompanyRow[] | null } const memberships = (await getUserCompanies(supabase, userId)) as unknown as MembershipRow[] const accessible = memberships.flatMap((membership) => { const company = Array.isArray(membership.companies) ? membership.companies[0] : membership.companies return company && company.archived_at === null ? [{ membership, company }] : [] }) const companyIds = accessible.map(({ company }) => company.id) const displayNames = new Map() if (companyIds.length > 0) { try { const settings = await fetchAllRows<{ company_id: string; company_name: string | null }>( ({ from, to }) => supabase .from('company_settings') .select('company_id, company_name') .in('company_id', companyIds) .order('company_id', { ascending: true }) .range(from, to), ) for (const row of settings) { if (row.company_name) displayNames.set(row.company_id, row.company_name) } } catch (error) { log.warn('gnubok_list_companies display-name lookup failed', { error: error instanceof Error ? error.message : 'unknown', }) } } const companies = accessible.map(({ membership, company }) => ({ company_id: company.id, name: displayNames.get(company.id) ?? company.name, org_number: company.org_number, entity_type: company.entity_type, role: membership.role, is_default: company.id === defaultCompanyId, })) const hasAccessibleDefault = companies.some((company) => company.is_default) return { companies, count: companies.length, default_company_id: hasAccessibleDefault ? defaultCompanyId : null, } }, }, { name: 'gnubok_get_company_settings', title: 'Get Company Settings', description: 'Get invoice payment details, company contact details and the custom invoice email texts. Use before creating invoices or staging a settings update.', inputSchema: { type: 'object', additionalProperties: false, properties: {}, }, outputSchema: { type: 'object', additionalProperties: false, properties: { company_id: { type: 'string' }, bank_name: { type: ['string', 'null'] }, clearing_number: { type: ['string', 'null'] }, account_number: { type: ['string', 'null'] }, bankgiro: { type: ['string', 'null'] }, plusgiro: { type: ['string', 'null'] }, swish: { type: ['string', 'null'] }, iban: { type: ['string', 'null'] }, bic: { type: ['string', 'null'] }, contact_person: { type: ['string', 'null'], description: 'Default Our reference value on new invoices.' }, email: { type: ['string', 'null'], description: 'Company contact email shown on invoices.' }, phone: { type: ['string', 'null'], description: 'Company contact phone shown on invoices.' }, website: { type: ['string', 'null'], description: 'Company website shown on invoices.' }, invoice_email_texts: { type: ['object', 'null'], additionalProperties: false, description: 'Per-language overrides of the invoice email texts. Null or a missing field means the standard text is used.', properties: { sv: { type: 'object', additionalProperties: false, properties: { subject: { type: 'string' }, greeting: { type: 'string' }, body: { type: 'string' }, signoff: { type: 'string' }, }, }, en: { type: 'object', additionalProperties: false, properties: { subject: { type: 'string' }, greeting: { type: 'string' }, body: { type: 'string' }, signoff: { type: 'string' }, }, }, }, }, }, required: [ 'company_id', 'bank_name', 'clearing_number', 'account_number', 'bankgiro', 'plusgiro', 'swish', 'iban', 'bic', 'contact_person', 'email', 'phone', 'website', 'invoice_email_texts', ], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, catalogVisibility: 'search', async execute(_args, companyId, _userId, supabase) { const { data, error } = await supabase .from('company_settings') .select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference, email, phone, website, invoice_email_texts') .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!data) throw new Error('Company settings not found.') return { company_id: companyId, bank_name: data.bank_name ?? null, clearing_number: data.clearing_number ?? null, account_number: data.account_number ?? null, bankgiro: data.bankgiro ?? null, plusgiro: data.plusgiro ?? null, swish: data.swish ?? null, iban: data.iban ?? null, bic: data.bic ?? null, contact_person: data.default_our_reference ?? null, email: data.email ?? null, phone: data.phone ?? null, website: data.website ?? null, invoice_email_texts: data.invoice_email_texts ?? null, } }, }, { name: 'gnubok_update_company_settings', title: 'Update Company Settings', description: 'Stage changes to invoice payment details, company contact details or the custom invoice email texts. Requires approval before company settings are updated.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { bank_name: { type: 'string', maxLength: 100 }, clearing_number: { type: 'string', description: '4-5 digits. Empty string clears the value.' }, account_number: { type: 'string', description: '6-12 digits. Empty string clears the value.' }, bankgiro: { type: ['string', 'null'], description: 'Valid 7-8 digit Bankgiro with Luhn check digit. Null or empty string clears it.' }, plusgiro: { type: ['string', 'null'], description: 'Valid Plusgiro with hyphen and Luhn check digit. Null or empty string clears it.' }, swish: { type: ['string', 'null'], description: 'Swedish business or mobile Swish number. Null clears it.' }, iban: { type: ['string', 'null'], description: 'Swedish IBAN: SE followed by 22 digits. Null or empty string clears it.' }, bic: { type: ['string', 'null'], description: '8 or 11 character BIC/SWIFT. Null or empty string clears it.' }, contact_person: { type: ['string', 'null'], maxLength: 200, description: 'Default Our reference value on new invoices. Null clears it.' }, email: { type: 'string', format: 'email', description: 'Company contact email shown on invoices. Empty string clears it.' }, phone: { type: 'string', description: 'Company contact phone shown on invoices. Empty string clears it.' }, website: { type: 'string', description: 'Company website shown on invoices. Empty string clears it.' }, invoice_email_texts: { type: ['object', 'null'], additionalProperties: false, description: 'Overrides the invoice email texts per language, standard invoices only. Omit a field to keep the standard text. Null clears every override.', properties: { sv: { type: 'object', additionalProperties: false, description: 'Swedish texts. Only these placeholders are allowed: {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp}. Any other {token} is rejected.', properties: { subject: { type: 'string', maxLength: 200 }, greeting: { type: 'string', maxLength: 200 }, body: { type: 'string', maxLength: 2000 }, signoff: { type: 'string', maxLength: 200 }, }, }, en: { type: 'object', additionalProperties: false, description: 'English texts, used when the customer language is en. Same placeholder set as sv.', properties: { subject: { type: 'string', maxLength: 200 }, greeting: { type: 'string', maxLength: 200 }, body: { type: 'string', maxLength: 2000 }, signoff: { type: 'string', maxLength: 200 }, }, }, }, }, dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' }, }, }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { const rawChanges: Record = {} for (const key of [ 'bank_name', 'clearing_number', 'account_number', 'bankgiro', 'plusgiro', 'swish', 'iban', 'bic', 'email', 'phone', 'website', 'invoice_email_texts', ]) { if (args[key] !== undefined) rawChanges[key] = args[key] } if (args.contact_person !== undefined) { rawChanges.default_our_reference = args.contact_person } const parsed = UpdateCompanySettingsParamsSchema.safeParse({ changes: rawChanges }) if (!parsed.success) { const issue = parsed.error.issues[0] throw new Error(`Invalid company settings: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`) } const { data: current, error } = await supabase .from('company_settings') .select('bank_name, clearing_number, account_number, bankgiro, plusgiro, swish, iban, bic, default_our_reference, email, phone, website, invoice_email_texts') .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!current) throw new Error('Company settings not found.') const currentPreview = { company_id: companyId, bank_name: current.bank_name ?? null, clearing_number: current.clearing_number ?? null, account_number: current.account_number ?? null, bankgiro: current.bankgiro ?? null, plusgiro: current.plusgiro ?? null, swish: current.swish ?? null, iban: current.iban ?? null, bic: current.bic ?? null, contact_person: current.default_our_reference ?? null, email: current.email ?? null, phone: current.phone ?? null, website: current.website ?? null, invoice_email_texts: current.invoice_email_texts ?? null, } const previewChanges = { ...parsed.data.changes, ...(parsed.data.changes.default_our_reference !== undefined ? { contact_person: parsed.data.changes.default_our_reference } : {}), } delete (previewChanges as Record).default_our_reference return stagePendingOperation( supabase, companyId, userId, 'update_company_settings', 'Uppdatera företagsinställningar', parsed.data, { current: currentPreview, changes: previewChanges, proposed: { ...currentPreview, ...previewChanges }, }, actor, undefined, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, { name: 'gnubok_list_skills', title: 'List Domain Skills', description: 'List domain-knowledge skills for this company (entity type, VAT, payroll). Workflow guides + loaded specialty atoms. Pass include_all=true to see hidden skills. Call gnubok_load_skill(slug) for any body.', inputSchema: { type: 'object', additionalProperties: false, properties: { tag: { type: 'string', description: 'Optional filter by tag (e.g. "vat", "monthly", "yearly", "payroll", or the tier name "workflow"/"horizontal"/"vertical"/"modifier").' }, tier: { type: 'string', enum: ['workflow', 'horizontal', 'vertical', 'modifier'], description: 'Optional filter by tier. workflow = static guides, horizontal = regulatory atoms (Swedish VAT/payroll/…), vertical = industry atoms (konsult-IT, e-handel…), modifier = cross-cutting atoms (holding-AB…).', }, include_all: { type: 'boolean', description: 'When true, ignore the company-context filter (entity_type, employees, vat_registered) and return all skills. Default false.', }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { skills: { type: 'array', items: { type: 'object', properties: { slug: { type: 'string' }, name: { type: 'string' }, summary: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, tier: { type: 'string', enum: ['workflow', 'horizontal', 'vertical', 'modifier'] }, }, required: ['slug', 'name', 'summary', 'tier'], }, }, count: { type: 'number' }, hidden_count: { type: 'number', description: 'Skills hidden by company-context filter. Re-call with include_all=true to see them.' }, company_context: { type: 'object', additionalProperties: false, description: 'Snapshot of the filter inputs used to compute the list: useful when debugging "why isn\'t skill X showing up?".', properties: { entity_type: { type: ['string', 'null'] }, has_employees: { type: 'boolean' }, vat_registered: { type: 'boolean' }, }, }, }, required: ['skills', 'count', 'hidden_count', 'company_context'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const tag = (args.tag as string | undefined)?.toLowerCase().trim() const tier = (args.tier as SkillTier | undefined) const includeAll = args.include_all === true // Resolve company context: read once per call. Failures degrade // gracefully: an unresolved field means "don't filter on it" so a // misconfigured company still gets the full skill list. const [settings, employeeCount] = await Promise.all([ supabase .from('company_settings') .select('entity_type, vat_registered') .eq('company_id', companyId) .maybeSingle(), supabase .from('employees') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .eq('is_active', true), ]) const entityType = (settings.data?.entity_type as string | undefined) ?? null const vatRegistered = Boolean(settings.data?.vat_registered) const hasEmployees = (employeeCount.count ?? 0) > 0 const all = await loadAllSkills(supabase) // First pass: tier + tag filter (unchanged). const tagFiltered = all.filter((s) => { if (tier && s.tier !== tier) return false if (tag && !s.tags.some((t) => t.toLowerCase() === tag)) return false return true }) // Second pass: applicability filter: skipped when include_all=true so // agents can always escape to the full list. Skills without an // applicability declaration are always shown (universal). const applicable = includeAll ? tagFiltered : tagFiltered.filter((s) => { if (!s.applicability) return true const a = s.applicability if (a.entity_type && a.entity_type !== 'both' && entityType && entityType !== a.entity_type) return false if (a.requires?.includes('employees') && !hasEmployees) return false if (a.requires?.includes('vat_registered') && !vatRegistered) return false return true }) return { skills: applicable.map((s) => ({ slug: s.slug, name: s.name, summary: s.summary, tags: s.tags, tier: s.tier, })), count: applicable.length, hidden_count: tagFiltered.length - applicable.length, company_context: { entity_type: entityType, has_employees: hasEmployees, vat_registered: vatRegistered, }, } }, }, { name: 'gnubok_load_skill', title: 'Load Domain Skill', description: 'Load a skill body by slug. Workflow slugs are flat (e.g. "month-end-close"); atom slugs match registry ids (e.g. "vertical/konsult-it", "modifier/holding-ab"). Call gnubok_list_skills to find slugs.', inputSchema: { type: 'object', additionalProperties: false, properties: { slug: { type: 'string', description: 'Skill slug: workflow slug ("month-end-close", "quarterly-vat-review", "year-end-close", "invoicing-rules", "payroll-monthly") or atom id ("vertical/konsult-it", "modifier/holding-ab", "horizontal/swedish-vat", …).' }, }, required: ['slug'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { slug: { type: 'string' }, name: { type: 'string' }, summary: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, tier: { type: 'string', enum: ['workflow', 'horizontal', 'vertical', 'modifier'] }, body: { type: 'string', description: 'Full skill content as Markdown' }, }, required: ['slug', 'name', 'body', 'tier'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const slug = (args.slug as string | undefined)?.trim() if (!slug) throw new Error('slug is required') const skill = await findSkill(slug, supabase) if (!skill) { const all = await loadAllSkills(supabase) const available = all.map((s) => s.slug).join(', ') throw new Error(`Skill not found: "${slug}". Available skills: ${available}`) } // Every load, every tier: records which skill/atom bodies agents // actually pull (mcp.skill_loaded). Without this, "which atom was // loaded" is unanswerable and atom effectiveness can't be measured. if (actor) { emitSkillLoaded({ slug: skill.slug, tier: skill.tier, actor, userId, companyId }) } // Workflow-tier skills are the closed-form processes (month-end-close, // year-end-close, payroll-monthly). Loading one is a strong signal the // agent is starting that workflow: emit so we can track completion // rates. Atom skills are reference material and don't trigger this. if (skill.tier === 'workflow' && actor) { emitWorkflowStarted({ slug: skill.slug, actor, userId, companyId }) } return { slug: skill.slug, name: skill.name, summary: skill.summary, tags: skill.tags, tier: skill.tier, body: skill.body, } }, }, { name: 'gnubok_remember_fact', title: 'Remember Company Fact', description: 'Capture a durable fact, preference, or correction about the company. Use mid-conversation when the user says something to remember next time. Writes immediately: does not stage. Use sparingly.', inputSchema: { type: 'object', additionalProperties: false, properties: { content: { type: 'string', description: 'The full fact text in the user\'s language. Self-contained: readable without prior context. Example: "Företaget hyr lagerplats av AB Foo, hyresfaktura kommer 25:e varje månad."', }, kind: { type: 'string', enum: ['fact', 'preference', 'pattern', 'correction'], description: 'fact = verifiable statement, preference = user-stated choice, pattern = observed regularity, correction = agent learned from a user fix. Default fact.', }, source_ref: { type: 'string', description: 'Optional pointer to where this fact came from (e.g. "conversation::turn-3").', }, relevance_score: { type: 'number', description: 'How important this memory is for future prompts. 0.0-1.0. Default 0.8 for agent-captured facts.', }, }, required: ['content'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read fact_id instead' }, fact_id: { type: 'string' }, kind: { type: 'string' }, content: { type: 'string' }, created_at: { type: 'string' }, }, required: ['id', 'fact_id', 'kind', 'content', 'created_at'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const content = (args.content as string | undefined)?.trim() if (!content || content.length < 2) throw new Error('content is required (min 2 chars)') const kind = (args.kind as string | undefined) ?? 'fact' if (!['fact', 'preference', 'pattern', 'correction'].includes(kind)) { throw new Error(`invalid kind: ${kind}`) } const rawScore = args.relevance_score const score = typeof rawScore === 'number' && rawScore >= 0 && rawScore <= 1 ? rawScore : 0.8 // Dedup guard: the agent re-remembers the same fact constantly (e.g. // "Vercel = omvänd skattskyldighet" on every Vercel categorization). // Before inserting, compare against existing active memories by // word-set Jaccard similarity. A near-duplicate (≥0.82) is treated as // already-known: we touch its updated_at + nudge relevance instead of // writing a new row, so agent_memory doesn't fill with paraphrases. // Bounded to the 300 most-recent active rows: dedup-on-write keeps // the working set small enough that this stays cheap. const { data: existing } = await supabase .from('agent_memory') .select('id, kind, content, created_at, relevance_score') .eq('company_id', companyId) .eq('is_active', true) .order('created_at', { ascending: false }) .limit(300) const incomingTokens = tokenizeForDedup(content) const dupe = (existing ?? []).find( (m: { content: string }) => jaccardSimilarity(incomingTokens, tokenizeForDedup(m.content)) >= 0.82, ) as { id: string; kind: string; content: string; created_at: string; relevance_score: number } | undefined if (dupe) { // Already known. Bump relevance toward the new score (max) and // refresh updated_at so recency-ordered recall still surfaces it. await supabase .from('agent_memory') .update({ relevance_score: Math.max(dupe.relevance_score ?? 0, score), updated_at: new Date().toISOString(), }) .eq('id', dupe.id) return { id: dupe.id, fact_id: dupe.id, kind: dupe.kind, content: dupe.content, created_at: dupe.created_at, } } const { data, error } = await supabase .from('agent_memory') .insert({ company_id: companyId, kind, content, source: 'agent_learned', source_ref: (args.source_ref as string | undefined) ?? null, relevance_score: score, is_active: true, created_by_user_id: userId, }) .select('id, kind, content, created_at') .single() if (error) throw new Error(`Failed to remember fact: ${error.message}`) return { ...data, fact_id: data.id } }, }, { name: 'gnubok_forget_fact', title: 'Forget Company Fact', description: 'Deactivate a memory entry by id. Use when the user explicitly asks to forget something or supersedes it. The row is kept for audit (is_active=false) but no longer surfaces in prompts.', inputSchema: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'The memory entry id from a prior gnubok_remember_fact call.' }, reason: { type: 'string', description: 'Optional short note about why this is being forgotten (for audit).' }, }, required: ['id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read fact_id instead' }, fact_id: { type: 'string' }, is_active: { type: 'boolean' }, }, required: ['id', 'fact_id', 'is_active'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const id = (args.id as string | undefined)?.trim() if (!id) throw new Error('id is required') const { data, error } = await supabase .from('agent_memory') .update({ is_active: false }) .eq('id', id) .eq('company_id', companyId) .select('id, is_active') .single() if (error) throw new Error(`Failed to forget fact: ${error.message}`) return { ...data, fact_id: data.id } }, }, { name: 'gnubok_feedback', title: 'Send Agent Feedback', description: 'Report agent-side feedback: missing tool, wrong description, skill gap, or a positive signal. Goes to event_log for product-team triage. Rate-limited 1/min/key.', inputSchema: { type: 'object', additionalProperties: false, properties: { context: { type: 'string', description: 'What you were trying to do and what blocked you, or what worked well. Free text, max 2000 chars.', }, sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'], description: 'Direction of the feedback. Default: negative.', }, suggestion: { type: 'string', description: 'Optional concrete suggestion (e.g. "add a tool for X", "rename Y arg").', }, tool_name: { type: 'string', description: 'Optional specific tool the feedback concerns.', }, skill_slug: { type: 'string', description: 'Optional specific skill the feedback concerns.', }, }, required: ['context'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { recorded: { type: 'boolean' }, message: { type: 'string' }, }, required: ['recorded', 'message'], }, annotations: { // Not read-only: this writes a telemetry event to the bus and mutates the // in-process rate-limit map. readOnlyHint is about side effects, not whether // business state changes: so it must be false even though no ledger is touched. readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, _supabase, actor) { const context = (args.context as string | undefined)?.trim() if (!context) throw new Error('context is required') if (context.length > 2000) throw new Error('context is too long (max 2000 chars)') const sentiment = ((args.sentiment as string | undefined) ?? 'negative') as 'positive' | 'negative' | 'neutral' const suggestion = (args.suggestion as string | undefined)?.trim() || null const toolName = (args.tool_name as string | undefined)?.trim() || null const skillSlug = (args.skill_slug as string | undefined)?.trim() || null // Rate-limit per API key (or per user when no key id). 1 per 60 s. // In-memory + single-process: leaky bucket would be cleaner but the // signal here is product-team triage, not security; over-counting is // fine, occasional under-counting is fine. const rateKey = actor?.id ?? userId const now = Date.now() const last = feedbackRateLimit.get(rateKey) if (last && now - last < FEEDBACK_RATE_LIMIT_MS) { const waitSec = Math.ceil((FEEDBACK_RATE_LIMIT_MS - (now - last)) / 1000) throw new Error(`gnubok_feedback is rate-limited. Try again in ${waitSec}s.`) } feedbackRateLimit.set(rateKey, now) emitAfterResponse(() => eventBus .emit({ type: 'agent.feedback', payload: { context, sentiment, suggestion, toolName, skillSlug, sessionId: actor?.sessionId ?? null, actorType: actor?.type ?? 'api_key', actorId: actor?.id ?? null, actorLabel: actor?.label ?? null, userId, companyId, }, }) .catch((err) => console.error('[mcp] agent.feedback emit failed:', err))) return { recorded: true, message: 'Thanks. Feedback queued for product-team review. We aggregate signal weekly.', } }, }, { name: 'gnubok_get_agent_briefing', title: 'Get Agent Briefing', description: 'Bootstrap this company\'s accountant context in one call: user_name, profile_summary, atoms (gnubok_load_skill for bodies), top-30 memories, dimensions, and recommended_tools: per-workflow loadouts to batch-load in one ToolSearch select:a,b,c call. Call once at session start.', inputSchema: { type: 'object', additionalProperties: false, properties: {}, }, outputSchema: { type: 'object', additionalProperties: false, properties: { company: { type: 'object', additionalProperties: false, description: 'The company selected for this call. Confirm it is the entity the user means before staging a write. Pass company_id on later calls to keep working in a non-default company.', properties: { id: { type: 'string', description: 'Deprecated: read company_id instead.' }, company_id: { type: 'string', description: 'company_id selected for this call.' }, name: { type: ['string', 'null'] }, org_number: { type: ['string', 'null'] }, entity_type: { type: ['string', 'null'], description: 'e.g. "aktiebolag", "enskild_firma". Null if unset.' }, accounting_method: { type: ['string', 'null'], enum: ['accrual', 'cash', null], description: 'accrual = faktureringsmetoden: payment debits 19xx AND credits 1510 (both sides). cash = kontantmetoden: payment debits 19xx and books revenue + moms. Drives the settlement posting. Null defaults to accrual.', }, }, required: ['id', 'company_id'], }, user_name: { type: ['string', 'null'], description: 'Name of the person you are assisting: address them by it (their tilltalsnamn), not the owner in profile_summary. Null if unset.', }, profile_summary: { type: ['string', 'null'], description: 'Composer-generated one-paragraph summary of the company. Null if no agent profile exists yet (composer has not run).', }, atoms: { type: 'array', description: 'Atoms (horizontal/vertical/modifier skills) loaded for this company. Metadata only: call gnubok_load_skill(id) for the body.', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read atom_id instead.' }, atom_id: { type: 'string', description: 'Atom id (e.g. "horizontal/swedish-vat", "vertical/konsult-it", "modifier/holding-ab"). Use as gnubok_load_skill slug.' }, tier: { type: 'string', enum: ['horizontal', 'vertical', 'modifier'] }, title: { type: 'string' }, description: { type: 'string' }, }, required: ['id', 'atom_id', 'tier', 'title', 'description'], }, }, memory: { type: 'array', description: 'Top-30 active memories (facts, preferences, patterns, corrections) ranked by relevance and recency.', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read fact_id instead.' }, fact_id: { type: 'string', description: 'Pass to gnubok_forget_fact to deactivate.' }, kind: { type: 'string', enum: ['fact', 'preference', 'pattern', 'correction'] }, content: { type: 'string' }, relevance_score: { type: ['number', 'null'] }, }, required: ['id', 'fact_id', 'kind', 'content'], }, }, dimensions: { type: 'object', additionalProperties: false, description: 'Dimension registry snapshot (kostnadsställe/projekt). OMITTED when the company has none registered; presence means lines can be tagged via the dims bag on gnubok_create_voucher.', properties: { enabled: { type: 'boolean', description: 'When true, dims-bag values are validated against the registry.' }, dimensions: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { sie_dim_no: { type: 'number' }, name: { type: 'string' }, active_value_count: { type: 'number' }, required_on_accounts: { type: 'array', description: 'BAS accounts with an active required-rule: postings there are refused without a value for this dimension.', items: { type: 'string' }, }, default_on_accounts: { type: 'array', description: 'BAS accounts where a default/fixed rule auto-applies a value at draft creation.', items: { type: 'string' }, }, top_values: { type: 'array', description: 'Up to 10 active values; full list via gnubok_list_dimension_values.', items: { type: 'object', additionalProperties: false, properties: { code: { type: 'string' }, name: { type: 'string' }, }, required: ['code', 'name'], }, }, }, required: ['sie_dim_no', 'name', 'active_value_count', 'required_on_accounts', 'default_on_accounts', 'top_values'], }, }, }, required: ['enabled', 'dimensions'], }, ledger_context: { type: 'object', additionalProperties: false, description: 'Digest of how this company books things: top-5 counterparty + top-3 supplier patterns. Full picture (account usage, explicit rules, VAT profile, conventions) in the Accounted://ledger/context resource. Evidence is historical frequency, NOT permission to auto-book: weigh seen count AND recency, never a ratio alone. OMITTED when not computable.', properties: { resource_uri: { type: 'string', description: 'URI of the full ledger-context resource.' }, window_from: { type: 'string', description: 'Start of the rolling stats window (ISO date).' }, posted_entries_window: { type: 'number', description: 'Posted journal entries in the window. Low = thin evidence: treat patterns as weak.' }, top_counterparty_patterns: { type: 'array', description: 'Most frequent booked bank-feed counterparties with dominant booking. evidence = seen N in 12m, M agreed, last booked; below 0.7 agreement excluded.', items: { type: 'object', additionalProperties: false, properties: { counterparty: { type: 'string' }, dominant_category: { type: 'string' }, dominant_account_number: { type: ['string', 'null'] }, evidence: { type: 'object', additionalProperties: false, properties: { seen_12m: { type: 'number' }, agree: { type: 'number' }, last_booked: { type: 'string' }, }, required: ['seen_12m', 'agree', 'last_booked'], }, }, required: ['counterparty', 'dominant_category', 'dominant_account_number', 'evidence'], }, }, top_supplier_patterns: { type: 'array', description: 'Most invoiced suppliers (AP side) with dominant expense account and VAT treatment. Same evidence semantics.', items: { type: 'object', additionalProperties: false, properties: { supplier: { type: 'string' }, dominant_account_number: { type: 'string' }, vat_treatment: { type: ['string', 'null'] }, evidence: { type: 'object', additionalProperties: false, properties: { seen_12m: { type: 'number' }, agree: { type: 'number' }, last_booked: { type: 'string' }, }, required: ['seen_12m', 'agree', 'last_booked'], }, }, required: ['supplier', 'dominant_account_number', 'vat_treatment', 'evidence'], }, }, }, required: ['resource_uri', 'window_from', 'posted_entries_window', 'top_counterparty_patterns', 'top_supplier_patterns'], }, recommended_tools: { type: 'array', description: 'Per-workflow tool loadouts, ordered by call sequence. Deferred-loading harnesses batch-load a whole cluster in one call (ToolSearch select:a,b,c). Static; validated against the registry.', items: { type: 'object', additionalProperties: false, properties: { workflow: { type: 'string', description: 'Stable workflow key.' }, description: { type: 'string' }, skill: { type: 'string', description: 'Slug for gnubok_load_skill (full playbook).' }, tools: { type: 'array', items: { type: 'string' }, description: 'Exact tool names, ordered.', }, }, required: ['workflow', 'description', 'skill', 'tools'], }, }, }, required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory', 'recommended_tools'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { // Dimension registry is best-effort and cheap: one indexed read, skipped // output when empty (most companies never register dimensions: lazy // seeding means zero rows until first use). Errors never block the // briefing. const safeDimensionsRead = (async () => { try { return await supabase .from('dimensions') .select('id, sie_dim_no, name') .eq('company_id', companyId) .eq('is_active', true) .order('sort_order', { ascending: true }) .order('sie_dim_no', { ascending: true }) } catch { return { data: null, error: new Error('dimensions read failed') } } })() // Ledger-context digest is best-effort: a stats failure (e.g. RPC not // yet applied on a self-hosted install) omits the block, never blocks // the briefing. const safeLedgerDigest = (async () => { try { const ctx = await buildLedgerContext(supabase, companyId) return { resource_uri: 'Accounted://ledger/context', window_from: ctx.meta.window.from, posted_entries_window: ctx.meta.coverage.posted_entries_window, top_counterparty_patterns: ctx.counterparty_patterns.slice(0, 5).map((p) => ({ counterparty: p.counterparty, dominant_category: p.dominant.category, dominant_account_number: p.dominant.account_number, evidence: { seen_12m: p.evidence.seen_12m, agree: p.evidence.agree, last_booked: p.evidence.last_booked, }, })), top_supplier_patterns: ctx.supplier_patterns.slice(0, 3).map((s) => ({ supplier: s.supplier, dominant_account_number: s.dominant.account_number, vat_treatment: s.dominant.vat_treatment, evidence: { seen_12m: s.evidence.seen_12m, agree: s.evidence.agree, last_booked: s.evidence.last_booked, }, })), } } catch { return null } })() const [profileRes, memoryRes, userRes, companyRes, settingsRes, dimensionsRes] = await Promise.all([ supabase .from('agent_profiles') .select('profile_summary, horizontal_atoms, vertical_atoms, modifier_atoms') .eq('company_id', companyId) .maybeSingle(), supabase .from('agent_memory') .select('id, kind, content, relevance_score') .eq('company_id', companyId) .eq('is_active', true) .order('relevance_score', { ascending: false, nullsFirst: false }) .order('last_accessed_at', { ascending: false, nullsFirst: false }) .limit(30), // The user's own preferred name (profiles.full_name) so the agent can // address them correctly. Distinct from owner/signatory names that may // appear in profile_summary: those come from Bolagsverket via TIC and // describe the company, not necessarily the person chatting. Best-effort: // a failed read yields a null name, never a thrown briefing. supabase .from('profiles') .select('full_name') .eq('id', userId) .maybeSingle(), // Company identity so the agent can confirm which entity it operates on // before any write. The dispatcher has already resolved and authorized // the optional per-call company_id. A failed read still yields a company // block with at least the id. supabase .from('companies') .select('name, org_number, entity_type') .eq('id', companyId) .maybeSingle(), supabase .from('company_settings') .select('accounting_method, dimensions_enabled') .eq('company_id', companyId) .maybeSingle(), safeDimensionsRead, ]) if (profileRes.error) throw new Error(`Failed to load agent profile: ${profileRes.error.message}`) if (memoryRes.error) throw new Error(`Failed to load agent memory: ${memoryRes.error.message}`) const profile = profileRes.data as | { profile_summary: string | null horizontal_atoms: string[] | null vertical_atoms: string[] | null modifier_atoms: string[] | null } | null const memoryRows = (memoryRes.data ?? []) as Array<{ id: string kind: string content: string relevance_score: number | null }> // profiles read is best-effort: ignore userRes.error so a missing name // never blocks the briefing. Data minimisation (GDPR Art.5(1)(c)): the // agent only needs the tilltalsnamn to address the user, so pass the first // token only (never the full legal name) into the LLM prompt. Mirrors // app/api/agent/invoke/route.ts, which also derives firstName via split. const userName = (((userRes.data as { full_name: string | null } | null)?.full_name ?? '') .trim() .split(/\s+/)[0] || null) // Company identity is best-effort: a missing row never blocks the // briefing. The id is always known (it scopes every query above). const companyRow = companyRes.data as | { name: string | null; org_number: string | null; entity_type: string | null } | null const settingsRow = settingsRes.data as | { accounting_method: string | null; dimensions_enabled?: boolean | null } | null const company = { id: companyId, company_id: companyId, name: companyRow?.name ?? null, org_number: companyRow?.org_number ?? null, entity_type: companyRow?.entity_type ?? null, accounting_method: settingsRow?.accounting_method ?? null, } // Dimensions block: skipped entirely when the registry is empty so an // untagged company pays nothing (and the agent isn't told about a // feature with no data behind it). const dimensionRows = (dimensionsRes.error ? [] : dimensionsRes.data ?? []) as Array<{ id: string sie_dim_no: number name: string }> let dimensionsBlock: | { enabled: boolean dimensions: Array<{ sie_dim_no: number name: string active_value_count: number required_on_accounts: string[] default_on_accounts: string[] top_values: Array<{ code: string; name: string }> }> } | undefined if (dimensionRows.length > 0) { try { const { data: valueRows, error: valueErr } = await supabase .from('dimension_values') .select('dimension_id, code, name') .eq('company_id', companyId) .eq('is_active', true) .order('code', { ascending: true }) if (!valueErr) { const byDimension = new Map>() for (const v of (valueRows ?? []) as Array<{ dimension_id: string; code: string; name: string }>) { const bucket = byDimension.get(v.dimension_id) ?? [] bucket.push({ code: v.code, name: v.name }) byDimension.set(v.dimension_id, bucket) } // Account dimension rules (PR10): tell the agent up front which // accounts refuse postings without a value (required) and which // auto-apply one (default/fixed) — so gnubok_create_voucher calls // self-correct instead of bouncing off MANDATORY_DIMENSION_MISSING. const requiredByDimension = new Map() const defaultByDimension = new Map() const { data: ruleRows, error: ruleErr } = await supabase .from('account_dimension_rules') .select('account_number, rule_type, dimension_id') .eq('company_id', companyId) .eq('is_active', true) if (!ruleErr) { for (const r of (ruleRows ?? []) as Array<{ account_number: string; rule_type: string; dimension_id: string }>) { const target = r.rule_type === 'required' ? requiredByDimension : defaultByDimension const bucket = target.get(r.dimension_id) ?? [] bucket.push(r.account_number) target.set(r.dimension_id, bucket) } } dimensionsBlock = { enabled: settingsRow?.dimensions_enabled === true, dimensions: dimensionRows.map((d) => { const values = byDimension.get(d.id) ?? [] return { sie_dim_no: d.sie_dim_no, name: d.name, active_value_count: values.length, required_on_accounts: (requiredByDimension.get(d.id) ?? []).sort(), default_on_accounts: (defaultByDimension.get(d.id) ?? []).sort(), top_values: values.slice(0, 10), } }), } } } catch { // Best-effort: a values-read failure just omits the block. } } const atomIds = [ ...(profile?.horizontal_atoms ?? []), ...(profile?.vertical_atoms ?? []), ...(profile?.modifier_atoms ?? []), ] let atoms: Array<{ id: string; atom_id: string; tier: string; title: string; description: string }> = [] if (atomIds.length > 0) { const { data: atomRows, error: atomErr } = await supabase .from('agent_atom_registry') .select('id, tier, title, description') .in('id', atomIds) .eq('is_active', true) if (atomErr) throw new Error(`Failed to load atom metadata: ${atomErr.message}`) atoms = ((atomRows ?? []) as Array<{ id: string tier: string title: string | null description: string }>).map((r) => ({ id: r.id, atom_id: r.id, tier: r.tier, title: r.title ?? r.id, // Trim the keyword-stuffed registry description to a clean one-liner: // bodies are fetched via gnubok_load_skill, not from this metadata. description: toSummary(r.description), })) } const ledgerDigest = await safeLedgerDigest return { company, user_name: userName, profile_summary: profile?.profile_summary ?? null, atoms, memory: memoryRows.map((m) => ({ id: m.id, fact_id: m.id, kind: m.kind, content: m.content, relevance_score: m.relevance_score, })), ...(dimensionsBlock ? { dimensions: dimensionsBlock } : {}), ...(ledgerDigest ? { ledger_context: ledgerDigest } : {}), // Static per-workflow loadouts (issue #1098): lets a deferred-loading // harness batch-load a whole workflow cluster in one call. Validated // against the tool registry at module init (assertRecommendedLoadoutsValid). recommended_tools: RECOMMENDED_WORKFLOW_LOADOUTS.map((w) => ({ workflow: w.workflow, description: w.description, skill: w.skill, tools: [...w.tools], })), } }, }, { name: 'gnubok_create_transactions', title: 'Create Bank Transactions', description: 'Stage one or more transactions for the user to approve. Each creates a separate pending operation; commit each via gnubok_approve_pending_operation. Use for ingesting external rows (Airtable, CSV). Max 10.', outputSchema: { type: 'object', additionalProperties: false, properties: { staged_count: { type: 'number', description: 'Number of items successfully staged.' }, operations: { type: 'array', items: STAGED_OPERATION_SCHEMA, description: 'One staged-operation result per input item, in the same order.', }, }, required: ['staged_count', 'operations'], }, inputSchema: { type: 'object', additionalProperties: false, properties: { transactions: { type: 'array', minItems: 1, maxItems: 10, description: 'Up to 10 transactions to stage. Each becomes its own pending operation.', items: { type: 'object', properties: { date: { type: 'string', description: 'Transaction date (YYYY-MM-DD).' }, amount: { type: 'number', description: 'Positive = income, negative = expense.' }, description: { type: 'string', description: 'Free-text description shown in /transactions.' }, currency: { type: 'string', description: 'ISO 4217 code. Default SEK.' }, ledger_account: { type: 'string', description: 'Optional BAS 19xx cash account (e.g. "1935") this row settles on. Binds it to a manual kassakonto so reconciliation and voucher matching resolve the right account instead of falling back to 1930.' }, bank_connection_id: { type: 'string', description: 'Optional UUID of a bank_connections row to associate with.' }, external_id: { type: 'string', description: 'Optional external reference (e.g., Airtable record ID). Shown in the preview; the DB enforces uniqueness per user, so the second commit of the same external_id will fail at approval.' }, }, required: ['date', 'amount', 'description'], }, }, }, required: ['transactions'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const items = args.transactions as Array> | undefined if (!Array.isArray(items) || items.length === 0) { throw new Error('transactions must be a non-empty array.') } if (items.length > 10) { throw new Error('transactions exceeds the per-call limit of 10. Split into multiple calls.') } const operations = [] for (let i = 0; i < items.length; i++) { const item = items[i] const date = item.date as string const amount = Number(item.amount) const description = ((item.description as string) ?? '').trim() const currency = ((item.currency as string) || 'SEK').toUpperCase() const ledgerAccount = (item.ledger_account as string) || null const bankConnectionId = (item.bank_connection_id as string) || null const externalId = (item.external_id as string) || null if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { throw new Error(`transactions[${i}].date must be in YYYY-MM-DD format.`) } if (!Number.isFinite(amount)) { throw new Error(`transactions[${i}].amount must be a finite number.`) } if (!description) { throw new Error(`transactions[${i}].description is required.`) } // Restrict the hint to BAS group 19 (kassa/bank): binding a transaction // to a non-cash account would misroute reconciliation and matching. if (ledgerAccount && !/^19\d{2}$/.test(ledgerAccount)) { throw new Error(`transactions[${i}].ledger_account must be a BAS 19xx cash account (e.g. "1935").`) } const params = { date, amount, description, currency, ledger_account: ledgerAccount, bank_connection_id: bankConnectionId, external_id: externalId, } const sign = amount >= 0 ? '+' : '' const titleSuffix = externalId ? ` [${externalId}]` : '' const title = `Ny transaktion: ${description} ${sign}${amount} ${currency}${titleSuffix}` const staged = await stagePendingOperation( supabase, companyId, userId, 'create_transaction', title, params, params, // params ARE the preview actor, { description: 'Once approved, the transaction lands in /transactions as uncategorized. Use gnubok_categorize_transaction to book it.', tool: 'gnubok_categorize_transaction', }, { dateForPeriodCheck: date }, ) operations.push(staged) } return { staged_count: operations.length, operations, } }, }, { name: 'gnubok_list_uncategorized_transactions', title: 'List Uncategorized Transactions', description: 'List bank transactions with no journal entry yet, newest first. Paginated.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results to return, 1-100 (default 20)' }, offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' }, }, }, outputSchema: paginatedSchema('transactions', { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read transaction_id instead' }, transaction_id: { type: 'string' }, date: { type: 'string' }, description: { type: 'string' }, amount: { type: 'number' }, currency: { type: 'string' }, merchant_name: { type: ['string', 'null'] }, reference: { type: ['string', 'null'] }, is_business: { type: ['boolean', 'null'] }, category: { type: ['string', 'null'] }, }, }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100) const offset = Math.max(0, Number(args.offset) || 0) // Get total count const { count: totalCount, error: countError } = await supabase .from('transactions') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .is('journal_entry_id', null) if (countError) throw new Error(`Database error: ${countError.message}`) const { data, error } = await supabase .from('transactions') .select( 'id, date, description, amount, currency, merchant_name, reference, is_business, category' ) .eq('company_id', companyId) .is('journal_entry_id', null) .order('date', { ascending: false }) .range(offset, offset + limit - 1) if (error) throw new Error(`Database error: ${error.message}`) const rows = (data ?? []).map((t: { id: string }) => ({ ...t, transaction_id: t.id })) const total = totalCount ?? 0 const hasMore = total > offset + rows.length return { transactions: rows, count: rows.length, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + rows.length } : {}), } }, }, { name: 'gnubok_list_transactions_without_documents', title: 'List Transactions Missing Receipts', description: 'List booked bank transactions whose verifikat lacks an underlag. Strict subset of gnubok_list_verifikat_without_documents (same document truth, waivers respected): use that tool for full coverage incl. imported/manual verifikat.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results to return, 1-100 (default 20)' }, offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' }, since: { type: 'string', description: 'Optional ISO date (YYYY-MM-DD). Only return transactions on or after this date.' }, }, }, outputSchema: paginatedSchema('transactions', { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read transaction_id instead' }, transaction_id: { type: 'string' }, date: { type: 'string' }, description: { type: 'string' }, amount: { type: 'number' }, currency: { type: 'string' }, merchant_name: { type: ['string', 'null'] }, reference: { type: ['string', 'null'] }, is_business: { type: ['boolean', 'null'] }, category: { type: ['string', 'null'] }, journal_entry_id: { type: 'string' }, }, }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100) const offset = Math.max(0, Number(args.offset) || 0) const since = typeof args.since === 'string' ? args.since : null // Same document truth as the verifikat surface: the RPC keys "has // underlag" on document_attachments (current version) + waivers, never // transactions.document_id: the two columns diverged historically // (P1-3, dev_docs/mcp_optimization_plan.md) and this surface is the // bank-driven SUBSET of gnubok_list_verifikat_without_documents by // construction. const { data, error } = await supabase.rpc('transactions_without_documents', { p_company_id: companyId, p_since: since, p_limit: limit, p_offset: offset, }) if (error) throw new Error(`Database error: ${error.message}`) const result = data as { ok: boolean code?: string total_count?: number transactions?: unknown[] } | null if (!result?.ok) { throw new Error(`transactions_without_documents failed: ${result?.code ?? 'unknown error'}`) } const rows = result.transactions ?? [] const total = result.total_count ?? 0 const hasMore = offset + rows.length < total return { transactions: rows, count: rows.length, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + rows.length } : {}), } }, }, { name: 'gnubok_list_verifikat_without_documents', title: 'List Verifikat Missing Documents', description: 'List posted verifikat that genuinely lack an underlag: needs-doc source types only, current document versions, user waivers respected. Superset of gnubok_list_transactions_without_documents (covers imported/manual too). Newest first, paginated.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results to return, 1-100 (default 20)' }, offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' }, since: { type: 'string', description: 'Optional ISO date (YYYY-MM-DD). Only return entries on or after this date.' }, min_amount: { type: 'number', description: 'Optional minimum gross amount (sum of debits) to filter low-value entries. Default 0.' }, }, }, outputSchema: paginatedSchema('verifikat', { type: 'object', additionalProperties: false, properties: { journal_entry_id: { type: 'string' }, voucher_series: { type: 'string' }, voucher_number: { type: 'number' }, entry_date: { type: 'string' }, description: { type: 'string' }, source_type: { type: 'string' }, gross_amount: { type: 'number' }, }, }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100) const offset = Math.max(0, Number(args.offset) || 0) const since = typeof args.since === 'string' ? args.since : null const minAmount = typeof args.min_amount === 'number' && Number.isFinite(args.min_amount) ? Math.max(0, args.min_amount) : 0 // gross_amount is an aggregate over journal_entry_lines, which PostgREST // cannot filter on: filtering it in memory after .range() made // total_count ignore min_amount and consecutive pages overlap. The RPC // filters, counts and paginates in SQL so the total respects the filter // and next_offset advances by exactly the rows consumed. const { data, error } = await supabase.rpc('verifikat_without_documents', { p_company_id: companyId, p_since: since, p_min_amount: minAmount, p_limit: limit, p_offset: offset, }) if (error) throw new Error(`Database error: ${error.message}`) const result = data as { ok: boolean code?: string total_count?: number verifikat?: unknown[] } | null if (!result?.ok) { throw new Error(`verifikat_without_documents failed: ${result?.code ?? 'unknown error'}`) } const rows = result.verifikat ?? [] const total = result.total_count ?? 0 const hasMore = offset + rows.length < total return { verifikat: rows, count: rows.length, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + rows.length } : {}), } }, }, { name: 'gnubok_categorize_transaction', title: 'Categorize Bank Transaction', description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross; dimensions bag tags the cost line. vat_amount overrides computed moms; reverse_charge rejected when the underlag shows seller VAT. Commit via gnubok_approve_pending_operation.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string', description: 'UUID of the transaction to categorize' }, category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] }, vat_treatment: { type: 'string', description: 'VAT treatment override. Defaults to standard_25 for business expenses. Set reverse_charge ONLY when the underlag confirms the seller did NOT charge VAT (omvänd skattskyldighet). An invoice with foreign VAT already debited is NOT reverse charge.', enum: [...VALID_VAT_TREATMENTS] }, vat_amount: { type: 'number', exclusiveMinimum: 0, description: 'The underlag\'s exact moms (> 0) when it differs from rate × belopp: e.g. dricks carries no VAT. Requires a rate-based vat_treatment. Swedish moms only: foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' }, notes: { type: 'string', description: 'Audit-trail context appended to the verifikation description. For category=representation use this to record deltagare + syfte ("Anna Andersson (Acme AB), kundmöte om Y"). For project work, include the project ref. Keep under 200 chars; pure metadata, not a re-description of the transaction.' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"1":"KS01","6":"P001"}. Tags the expense/business lines of the generated voucher: never the bank or VAT lines. Unknown values rejected, never auto-created.', }, allow_duplicate: { type: 'boolean', description: 'Override the duplicate-booking guard (default false). Set true ONLY after the user confirms this bank line is a genuinely separate event: the guard blocks a second verifikat for an event already booked (e.g. a paid invoice or a salary payout).' }, idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries: a replayed call returns the already-staged operation instead of staging twice.' }, }, required: ['transaction_id', 'category'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const vatAmount = typeof args.vat_amount === 'number' && Number.isFinite(args.vat_amount) ? args.vat_amount : undefined // MCP boundary gate: reject a malformed dims bag before the DB-heavy // categorization preview runs. Resolution happens right before staging. const inputDimensions = parseDimensionsArg(args.dimensions, 'dimensions') // Compute the preview (accounts, amounts, VAT lines) const result = await categorizeTransactionCore( args.transaction_id as string, args.category as TransactionCategory, args.vat_treatment as VatTreatment | undefined, vatAmount, userId, companyId, supabase, false // preview mode: execution happens at approval time via gnubok_approve_pending_operation ) // If already has a journal entry, pass through as-is if (result.success && result.journal_entry_created === false) { const { transaction: _tx, ...publicResult } = result return publicResult } // Fetch transaction description (and date for period_status) for the title const { data: tx } = await supabase .from('transactions') // amount_sek / exchange_rate are projected for the duplicate guard: it // compares this bank line against SEK ledger legs, and without them a // non-SEK row has no SEK value to compare with. .select('description, merchant_name, amount, currency, amount_sek, exchange_rate, date, cash_account_id') .eq('id', args.transaction_id as string) .eq('company_id', companyId) .single() // Booking-time duplicate guard: surface a likely double-booking to the // agent NOW (before staging) so it can link to the existing verifikat // instead of queuing a second one for approval. The commit executor // re-checks as the hard gate; this is the early, actionable signal. // Mirrors the web /categorize route's guard. if (args.allow_duplicate !== true && tx) { const txFx = tx as { cash_account_id?: string | null; amount_sek?: number | null; exchange_rate?: number | null } const dup = await detectBookingDuplicate(supabase, companyId, { id: args.transaction_id as string, date: tx.date, amount: tx.amount, // `amount` is denominated in `currency`; the ledger legs the guard // compares it against are always SEK, so the conversion fields have // to come along or a non-SEK line cannot be compared at all. currency: tx.currency ?? null, amount_sek: txFx.amount_sek ?? null, exchange_rate: txFx.exchange_rate ?? null, cash_account_id: txFx.cash_account_id ?? null, }) if (dup) { const voucher = dup.voucher_label ? `verifikat ${dup.voucher_label}` : 'en befintlig verifikation' // Shared three-branch claim (lib/transactions/categorize-core.ts): // SEK figure when verified, the foreign amount when the sibling has // no SEK value (dup.amount === null used to render "bokför null kr" // here), and an explicit could-not-compare that attributes the // missing rate to the TARGET row. One implementation for web + MCP. const claim = buildDuplicateBookingClaim(dup, tx.currency ?? null) throw new Error( `Möjlig dubblettbokföring: ${voucher} (${dup.entry_date}) ${claim}. ` + `Den här affärshändelsen ser redan ut att vara bokförd: länka transaktionen till den befintliga ` + `verifikationen i stället. Anropa igen med allow_duplicate=true först om det är en genuint separat affärshändelse.`, ) } } const txDesc = tx ? `${tx.merchant_name || tx.description || 'Transaktion'} ${tx.amount} ${tx.currency}` : String(args.transaction_id) // Resolve-don't-select: codes AND natural-language names resolve against // the registry in one pass (zero queries when untagged; free-text // passthrough while dimensions_enabled is off). The resolved bag lands on // the expense/business lines only: the executor never tags bank/VAT lines. const { bags: dimBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, [inputDimensions], ) const resolvedDimensions = dimBags[0] const hasDimensions = Boolean(resolvedDimensions && Object.keys(resolvedDimensions).length > 0) // Stage for user approval return stagePendingOperation(supabase, companyId, userId, 'categorize_transaction', `Kategorisera: ${txDesc}`, { transaction_id: args.transaction_id, category: args.category, vat_treatment: args.vat_treatment || null, vat_amount: vatAmount ?? null, notes: typeof args.notes === 'string' && args.notes.trim().length > 0 ? (args.notes as string).trim() : null, ...(hasDimensions ? { dimensions: resolvedDimensions } : {}), allow_duplicate: args.allow_duplicate === true, }, { debit_account: result.debit_account, credit_account: result.credit_account, amount: result.amount, currency: result.currency, // Exact journal lines the approval will post (net cost line, VAT // line, gross bank line, SEK). The summary fields above pair the // GROSS amount with the cost account — read alone they misled // users and agents into seeing an unbalanced entry. lines: result.lines ?? [], vat_lines: result.vat_lines || [], category: result.category, underlag: result.underlag ?? null, ...(hasDimensions ? { dimensions: resolvedDimensions } : {}), // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), }, actor, { description: 'Once approved, the journal entry is posted. Continue with gnubok_list_uncategorized_transactions to keep clearing the backlog, or lock the period once it is empty.', tool: 'gnubok_list_uncategorized_transactions', }, { ...(tx?.date ? { dateForPeriodCheck: tx.date } : {}), // Categorize is the tool agents blind-retry after ambiguous // client-side failures (approval elicitation drops): the key makes // that retry safe instead of double-staging. idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, // ── Receipt matcher tool ────────────────────────────────────── { name: 'gnubok_receipt_matcher', title: 'Receipt Matcher Widget', description: 'Open an interactive widget for drag-and-drop receipt-to-transaction matching. Renders inline in compatible clients.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max transactions to show, 1-50 (default 20)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { transactions: { type: 'array', items: { type: 'object' } }, categories: { type: 'array', items: { type: 'string' } }, vat_treatments: { type: 'array', items: { type: 'string' } }, }, required: ['transactions', 'categories', 'vat_treatments'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, _meta: { ui: { resourceUri: 'ui://receipt-matcher/app.html' } }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) const { data, error } = await supabase .from('transactions') .select( 'id, date, description, amount, currency, merchant_name, reference, is_business, category' ) .eq('company_id', companyId) .is('journal_entry_id', null) .order('date', { ascending: false }) .limit(limit) if (error) throw new Error(`Database error: ${error.message}`) return { transactions: data ?? [], categories: [...VALID_CATEGORIES], vat_treatments: [...VALID_VAT_TREATMENTS], } }, }, // ── Customer tools ─────────────────────────────────────────── { name: 'gnubok_list_customers', title: 'List Customers', description: 'List all customers for the active company. Use to look up customer_id for invoice creation.', inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', additionalProperties: false, properties: { customers: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['customers', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { const { data, error } = await supabase .from('customers') .select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country') .eq('company_id', companyId) .order('name') if (error) throw new Error(`Database error: ${error.message}`) return { customers: data, count: data?.length ?? 0 } }, }, { name: 'gnubok_create_customer', title: 'Create Customer', description: 'Stage a new customer. Stages for user approval: NOT created until approved in the web app. EU VAT numbers trigger VIES validation.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { name: { type: 'string', description: 'Customer name' }, customer_type: { type: 'string', enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'], description: 'Customer type', }, email: { type: 'string', description: 'Email address' }, org_number: { type: 'string', description: 'Swedish org number' }, vat_number: { type: 'string', description: 'EU VAT number' }, payment_terms: { type: 'number', description: 'Payment terms in days (default 30)' }, address: { type: 'string', description: 'Street address' }, postal_code: { type: 'string' }, city: { type: 'string' }, country: { type: 'string', description: 'Country (default Sweden)' }, dry_run: { type: 'boolean', description: 'If true, validate inputs and return the would-be preview without staging or creating. No DB writes, no side-effects.', }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.', }, }, required: ['name', 'customer_type'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, // safe to retry with idempotency_key openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const name = args.name as string const customerType = args.customer_type as string if (!name?.trim()) throw new Error('Customer name is required.') if (!['individual', 'swedish_business', 'eu_business', 'non_eu_business'].includes(customerType)) { throw new Error('Invalid customer_type. Must be: individual, swedish_business, eu_business, non_eu_business') } const params = { name: name.trim(), customer_type: customerType, email: (args.email as string) || null, org_number: (args.org_number as string) || null, vat_number: (args.vat_number as string) || null, payment_terms: Number(args.payment_terms) || 30, address: (args.address as string) || null, postal_code: (args.postal_code as string) || null, city: (args.city as string) || null, country: (args.country as string) || 'Sweden', } return stagePendingOperation(supabase, companyId, userId, 'create_customer', `Ny kund: ${params.name}`, params, params, // params ARE the preview for customers actor, { description: 'Once approved, you can invoice this customer with gnubok_create_invoice using the returned customer_id.', tool: 'gnubok_create_invoice', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, } ) }, }, { name: 'gnubok_update_customer', title: 'Update Customer', description: 'Stage a partial update to an existing customer. Find customer_id with gnubok_list_customers. Requires approval before customer data is changed.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { customer_id: { type: 'string', description: 'UUID from gnubok_list_customers.' }, name: { type: 'string', minLength: 1 }, customer_type: { type: 'string', enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'], description: 'Changing an individual customer to a business type clears its stored personal number.', }, customer_number: { type: ['string', 'null'], maxLength: 32, description: 'Null or empty string clears the customer number.' }, email: { type: 'string', format: 'email' }, phone: { type: 'string' }, address_line1: { type: 'string' }, address_line2: { type: 'string' }, postal_code: { type: 'string' }, city: { type: 'string' }, country: { type: 'string' }, org_number: { type: 'string' }, vat_number: { type: 'string', description: 'EU VAT numbers are revalidated with VIES when the update is approved.' }, language: { type: 'string', enum: ['sv', 'en'] }, default_payment_terms: { type: 'integer', minimum: 1 }, notes: { type: 'string' }, dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' }, }, required: ['customer_id'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { const changes: Record = {} for (const key of [ 'name', 'customer_type', 'customer_number', 'email', 'phone', 'address_line1', 'address_line2', 'postal_code', 'city', 'country', 'org_number', 'vat_number', 'language', 'default_payment_terms', 'notes', ]) { if (args[key] !== undefined) changes[key] = args[key] } const parsed = UpdateCustomerParamsSchema.safeParse({ customer_id: args.customer_id, changes, }) if (!parsed.success) { const issue = parsed.error.issues[0] throw new Error(`Invalid customer update: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`) } const { data: current, error } = await supabase .from('customers') .select('id, name, customer_type, customer_number, email, phone, address_line1, address_line2, postal_code, city, country, org_number, vat_number, vat_number_validated, language, default_payment_terms, notes') .eq('id', parsed.data.customer_id) .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!current) throw new Error('Customer not found.') const currentPreview = { customer_id: current.id, name: current.name, customer_type: current.customer_type, customer_number: current.customer_number ?? null, email: current.email ?? null, phone: current.phone ?? null, address_line1: current.address_line1 ?? null, address_line2: current.address_line2 ?? null, postal_code: current.postal_code ?? null, city: current.city ?? null, country: current.country, org_number: current.org_number ?? null, vat_number: current.vat_number ?? null, vat_number_validated: current.vat_number_validated ?? false, language: current.language ?? 'sv', default_payment_terms: current.default_payment_terms, notes: current.notes ?? null, } return stagePendingOperation( supabase, companyId, userId, 'update_customer', `Uppdatera kund: ${current.name}`, parsed.data, { current: currentPreview, changes: parsed.data.changes, proposed: { ...currentPreview, ...parsed.data.changes }, }, actor, undefined, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, // ── Article tools (artikelregister) ────────────────────────── { name: 'gnubok_list_articles', title: 'List Articles', description: "List the active company's catalog articles (artikelregister). Use to look up an article to add to an invoice line. Active articles only by default.", inputSchema: { type: 'object', additionalProperties: false, properties: { query: { type: 'string', description: 'Optional case-insensitive filter on name or article_number.' }, include_inactive: { type: 'boolean', description: 'Include deactivated articles (default false).' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { articles: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['articles', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let q = supabase .from('articles') .select('id, article_number, name, name_en, type, unit, price_excl_vat, vat_rate, revenue_account, housework_type, active') .eq('company_id', companyId) if (!args.include_inactive) q = q.eq('active', true) // Strip PostgREST filter metacharacters before interpolating into .or(): // commas/parens would otherwise let a query inject extra or-conditions, and // the ILIKE wildcards % and _ would turn a stray char into a match-all. const raw = typeof args.query === 'string' ? args.query : '' const safe = raw.replace(/[%_,()\\*]/g, ' ').trim() if (safe) { q = q.or(`name.ilike.%${safe}%,article_number.ilike.%${safe}%`) } const { data, error } = await q.order('name') if (error) throw new Error(`Database error: ${error.message}`) return { articles: data, count: data?.length ?? 0 } }, }, { name: 'gnubok_create_article', title: 'Create Article', description: 'Stage a new catalog article (artikelregister). Stages for approval: not created until approved. Article number auto-assigned. Reuse on invoice lines via gnubok_create_invoice.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { name: { type: 'string', description: 'Article name (prints on the invoice line).' }, type: { type: 'string', enum: ['vara', 'tjanst'], description: 'Good (vara) or service (tjanst). Default tjanst.' }, unit: { type: 'string', description: 'Unit, e.g. st, tim, kg. Default st.' }, price_excl_vat: { type: 'number', description: 'Unit price EXCLUDING VAT.' }, currency: { type: 'string', description: 'Price currency as ISO 4217 code (e.g. EUR). Default SEK. Pre-fills the invoice currency when the article is added.' }, vat_rate: { type: 'number', enum: [0, 6, 12, 25], description: 'VAT rate percent. Default 25.' }, revenue_account: { type: 'string', description: 'Optional BAS class-3 revenue account (e.g. 3041). Omit to derive from VAT.' }, cost_price: { type: 'number', description: 'Optional cost price (margin only; never booked).' }, ean: { type: 'string', description: 'Barcode / EAN.' }, housework_type: { type: 'string', description: 'ROT/RUT arbetstyp (services only).' }, name_en: { type: 'string', description: 'English name for English-language invoices.' }, notes: { type: 'string' }, article_number: { type: 'string', description: 'Optional manual number; omit to auto-generate.' }, dry_run: { type: 'boolean', description: 'Validate and preview without staging.' }, idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' }, }, required: ['name', 'price_excl_vat'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const name = (args.name as string)?.trim() if (!name) throw new Error('Article name is required.') if (typeof args.price_excl_vat !== 'number') { throw new Error('price_excl_vat is required and must be a number.') } const params: Record = { name, type: (args.type as string) || 'tjanst', unit: (args.unit as string) || undefined, price_excl_vat: args.price_excl_vat, currency: (args.currency as string) || undefined, vat_rate: typeof args.vat_rate === 'number' ? args.vat_rate : 25, revenue_account: (args.revenue_account as string) || null, cost_price: typeof args.cost_price === 'number' ? args.cost_price : null, ean: (args.ean as string) || null, housework_type: (args.housework_type as string) || null, name_en: (args.name_en as string) || null, notes: (args.notes as string) || null, article_number: (args.article_number as string) || null, } return stagePendingOperation(supabase, companyId, userId, 'create_article', `Ny artikel: ${name}`, params, params, // params ARE the preview actor, { description: 'Once approved, add it to an invoice with gnubok_create_invoice using the returned article fields.', tool: 'gnubok_create_invoice', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, } ) }, }, { name: 'gnubok_update_article', title: 'Update Article', description: 'Stage an edit to a catalog article (price, name, account, etc.) or deactivate it via active:false. Stages for approval. Find article_id with gnubok_list_articles.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { article_id: { type: 'string', description: 'UUID of the article to update.' }, name: { type: 'string' }, type: { type: 'string', enum: ['vara', 'tjanst'] }, unit: { type: 'string' }, price_excl_vat: { type: 'number' }, currency: { type: 'string', description: 'Price currency as ISO 4217 code (e.g. EUR), or omit to leave unchanged.' }, vat_rate: { type: 'number', enum: [0, 6, 12, 25] }, revenue_account: { type: 'string', description: 'BAS class-3 revenue account, or omit to leave unchanged.' }, cost_price: { type: 'number' }, ean: { type: 'string' }, housework_type: { type: 'string' }, name_en: { type: 'string' }, notes: { type: 'string' }, active: { type: 'boolean', description: 'Set false to deactivate (hide from pickers, keep history).' }, dry_run: { type: 'boolean' }, idempotency_key: { type: 'string' }, }, required: ['article_id'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const articleId = args.article_id as string if (!articleId) throw new Error('article_id is required.') const params: Record = { article_id: articleId } for (const key of [ 'name', 'type', 'unit', 'price_excl_vat', 'currency', 'vat_rate', 'revenue_account', 'cost_price', 'ean', 'housework_type', 'name_en', 'notes', 'active', ]) { if (args[key] !== undefined) params[key] = args[key] } return stagePendingOperation(supabase, companyId, userId, 'update_article', `Uppdatera artikel ${(args.name as string)?.trim() || articleId}`, params, params, actor, undefined, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, } ) }, }, // ── Invoice tools ──────────────────────────────────────────── { name: 'gnubok_list_invoices', title: 'List Customer Invoices', description: 'List invoices for the active company, newest first. Optional status filter.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited'], description: 'Filter by invoice status', }, limit: { type: 'number', description: 'Max results (default 50, max 100)' }, offset: { type: 'integer', minimum: 0, description: 'Number of results to skip for pagination (default 0)' }, }, }, outputSchema: paginatedSchema('invoices', { type: 'object' }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100) const offset = Math.max(0, Math.floor(Number(args.offset) || 0)) const status = args.status as string | undefined let query = supabase .from('invoices') .select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, default_dimensions, customers(name)', { count: 'exact' }) .eq('company_id', companyId) if (status) { query = query.eq('status', status) } const { data, error, count } = await query .order('invoice_date', { ascending: false }) .order('id', { ascending: false }) .range(offset, offset + limit) if (error) throw new Error(`Database error: ${error.message}`) const rows = data ?? [] const invoices = rows.slice(0, limit).map((inv: Record) => ({ id: inv.id, invoice_number: inv.invoice_number, status: inv.status, customer_name: (inv.customers as Record)?.name ?? null, total: inv.total, currency: inv.currency, invoice_date: inv.invoice_date, due_date: inv.due_date, document_type: inv.document_type, default_dimensions: inv.default_dimensions ?? {}, })) const hasMore = count == null ? rows.length > limit : offset + invoices.length < count const total = count ?? offset + invoices.length + (hasMore ? 1 : 0) return { invoices, count: invoices.length, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + invoices.length } : {}), } }, }, { name: 'gnubok_create_invoice', title: 'Create Customer Invoice', description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Items accept dims bags. Stages for user approval: invoice number assigned at approval.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { customer_id: { type: 'string', description: 'Customer UUID' }, items: { type: 'array', items: { type: 'object', properties: { description: { type: 'string' }, quantity: { type: 'number' }, unit: { type: 'string', description: 'st, tim, dag, mån' }, unit_price: { type: 'number', description: 'Price per unit excl. VAT' }, vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.', }, }, required: ['description', 'quantity', 'unit', 'unit_price'], }, description: 'Invoice line items', }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag keyed by SIE dim no, value = code OR name, e.g. {"1":"KS01","6":"Villa Almgren"}. Applied to every item not setting the key. Unknown values rejected: never auto-created.', }, invoice_date: { type: 'string', description: 'YYYY-MM-DD (default today)' }, due_date: { type: 'string', description: 'YYYY-MM-DD (default from payment terms)' }, currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] }, our_reference: { type: 'string' }, your_reference: { type: 'string' }, notes: { type: 'string' }, payment_link_url: { type: 'string', description: 'Optional https pay link for THIS invoice (e.g. Stripe); rendered in the invoice email and PDF.', }, }, required: ['customer_id', 'items'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const customerId = args.customer_id as string const items = args.items as Array<{ description: string quantity: number unit: string unit_price: number vat_rate?: number dimensions?: unknown }> if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.') if (!items?.length) throw new Error('At least one item is required.') for (const [i, item] of items.entries()) { if (!item.description?.trim()) throw new Error(`Item ${i + 1}: description is required`) if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${i + 1}: quantity must be positive`) if (!item.unit?.trim()) throw new Error(`Item ${i + 1}: unit is required (st, tim, dag)`) if (item.unit_price == null) throw new Error(`Item ${i + 1}: unit_price is required`) } // Resolve-don't-select: parse the invoice-level default bag + each item's // own bag, then resolve codes AND natural-language names against the // registry in ONE pass (zero queries when nothing is tagged; free-text // passthrough while dimensions_enabled is off). The resolved default is // staged top-level; each item keeps only its own resolved bag: the // executor merges item-over-default at commit time. const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, [defaultDimensions, ...items.map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`))], ) const resolvedDefaultDimensions = resolvedDimBags[0] const stagedItems = items.map((item, i) => { const { dimensions: _rawDimensions, ...rest } = item const bag = resolvedDimBags[i + 1] return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest }) // Same https-only gate as the web API (CreateInvoiceSchema): the link is // rendered in customer-facing emails/PDFs under the company's name. const paymentLinkUrl = (args.payment_link_url as string | undefined)?.trim() || null if (paymentLinkUrl) { let isHttps = false try { isHttps = new URL(paymentLinkUrl).protocol === 'https:' } catch { isHttps = false } if (!isHttps || paymentLinkUrl.length > 2048) { throw new Error('payment_link_url must be a valid https URL (max 2048 chars).') } } const today = new Date().toISOString().split('T')[0] const currency = ((args.currency as string) || 'SEK') as Currency const invoiceDate = (args.invoice_date as string) || today // Fetch customer (full row for VAT rules) const { data: customer, error: custError } = await supabase .from('customers') .select('*') .eq('id', customerId) .eq('company_id', companyId) .single() if (custError || !customer) { throw new Error('Customer not found. Use gnubok_list_customers to find valid IDs.') } // VAT rules from customer type (same logic as web UI) const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated) // Gate on the PERMITTED set, not the picker default, exactly like // buildInvoiceWriteData and commitCreateInvoice: huvudregeln (ML 6 kap. // 34 §) taxes a B2B service where the buyer is established, so 0% is the // default for a foreign business; but the ML 6 kap. supplies taxed where // they are performed (hotel/restaurang 12%, persontransport and event // admission 6%, fastighetstjänst and korttidsuthyrning 25%) carry Swedish // VAT even to a German or a US company. Gating on the default made a // Stockholm hotel night impossible to invoice through this tool at all. // The default is still 0% (vatRules.rate is the fallback below), so a // Swedish rate only reaches the staged operation when the agent set it on // that line explicitly. const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated) const allowedRates = new Set(permittedRates.map((r) => r.rate)) // Calculate per-item VAT const subtotal = items.reduce((s, item) => s + item.quantity * item.unit_price, 0) let vatAmount = 0 for (const item of items) { const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate if (!allowedRates.has(itemRate)) { throw new Error( `VAT rate ${itemRate}% is not allowed for customer type "${customer.customer_type}". ` + `Allowed rates: ${permittedRates.map((r) => r.rate + '%').join(', ')}` ) } const lineTotal = item.quantity * item.unit_price vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 } const total = subtotal + vatAmount // Due date from payment terms if not provided let dueDate = args.due_date as string | undefined if (!dueDate) { const d = new Date(invoiceDate) d.setDate(d.getDate() + (customer.default_payment_terms || 30)) dueDate = d.toISOString().split('T')[0] } // Stage for user approval instead of creating directly return stagePendingOperation(supabase, companyId, userId, 'create_invoice', `Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`, { customer_id: customerId, items: stagedItems, ...(resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0 ? { default_dimensions: resolvedDefaultDimensions } : {}), invoice_date: invoiceDate, due_date: dueDate, currency, our_reference: (args.our_reference as string) || null, your_reference: (args.your_reference as string) || null, notes: (args.notes as string) || null, payment_link_url: paymentLinkUrl, }, { customer_name: customer.name, customer_type: customer.customer_type, items: stagedItems.map(item => ({ ...item, line_total: item.quantity * item.unit_price, vat_rate: item.vat_rate ?? vatRules.rate, })), subtotal: Math.round(subtotal * 100) / 100, vat_amount: Math.round(vatAmount * 100) / 100, total: Math.round(total * 100) / 100, currency, vat_treatment: vatRules.treatment, invoice_date: invoiceDate, due_date: dueDate, // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), }, actor, { description: 'Once approved, the invoice is created as a draft. Send it with gnubok_send_invoice or use gnubok_mark_invoice_as_sent if delivered outside the system.', tool: 'gnubok_send_invoice', } ) }, }, // ── Report tools ───────────────────────────────────────────── { name: 'gnubok_get_trial_balance', title: 'Trial Balance (Råbalans)', description: 'Trial balance (huvudbok) for a fiscal period: all account balances with debit/credit totals. Defaults to most recent period. Optional dimensions filter scopes to tagged lines (kostnadsställe/projekt).', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { rows: { type: 'array', items: { type: 'object' } }, total_debit: { type: 'number' }, total_credit: { type: 'number' }, is_balanced: { type: 'boolean' }, period_name: { type: 'string' }, period_start: { type: 'string' }, period_end: { type: 'string' }, account_count: { type: 'number' }, ...DIMENSION_FILTER_OUTPUT_PROPS, }, required: ['rows', 'total_debit', 'total_credit', 'is_balanced'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined // If no period specified, find the most recent one if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id, name') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) { throw new Error('No fiscal periods found. Categorize some transactions first to auto-create a period.') } periodId = periods.id } // Get period info const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', periodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found.') // Optional dimensions filter: names resolve to registry codes first // (resolve-don't-select), then flow into the generator's jsonb // containment filter. const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions) // Delegate to the canonical, paginated trial-balance builder. The // previous inline query had no pagination, so PostgREST's 1000-row // default silently truncated any period with >1000 entry lines (wrong // sums, false "not balanced"), and it ignored opening balances. // generateTrialBalance paginates and rolls IB forward. const trialBalance = await generateTrialBalance( supabase, companyId, periodId!, // Saldobalans is the ledger as posted, resultatavslut included. dimFilter.filter ? { closingEntry: 'include' as const, dimensions: dimFilter.filter } : { closingEntry: 'include' as const }, ) const rows = trialBalance.rows .map((r) => { const net = Math.round((r.closing_debit - r.closing_credit) * 100) / 100 return { account_number: r.account_number, account_name: r.account_name, period_debit: r.period_debit, period_credit: r.period_credit, closing_debit: net > 0 ? net : 0, closing_credit: net < 0 ? Math.abs(net) : 0, } }) .sort((a, b) => a.account_number.localeCompare(b.account_number)) const totalDebit = Math.round(rows.reduce((s, r) => s + r.closing_debit, 0) * 100) / 100 const totalCredit = Math.round(rows.reduce((s, r) => s + r.closing_credit, 0) * 100) / 100 return { rows, total_debit: totalDebit, total_credit: totalCredit, is_balanced: Math.abs(totalDebit - totalCredit) < 0.01, period_name: period.name, period_start: period.period_start, period_end: period.period_end, account_count: rows.length, ...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}), ...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}), } }, }, { name: 'gnubok_get_vat_report', title: 'VAT Declaration (Momsdeklaration)', description: 'VAT declaration (momsdeklaration, SKV 4700) for a period. Returns all rutor; ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to also open the review widget (claude.ai / Desktop).', outputSchema: VAT_REPORT_OUTPUT_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type', }, year: { type: 'number', description: 'Year (e.g. 2025)' }, period: { type: 'number', description: '1-12 for monthly, 1-4 for quarterly, 1 for yearly' }, render_ui: { type: 'boolean', description: 'When true, also render the interactive momsdeklaration review widget (claude.ai / Claude Desktop). The structured rutor are returned either way. Default false.', }, }, required: ['period_type', 'year', 'period'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, // Renders the VAT widget only when the caller passes render_ui=true (the // dispatcher emits result-level _meta in that case). This is the merged // report+widget surface; gnubok_vat_review_widget remains as an alias. uiResourceUri: 'ui://vat-review/app.html', async execute(args, companyId, _userId, supabase) { return computeVatReport(args, companyId, supabase) }, }, { name: 'gnubok_vat_review_widget', title: 'VAT Review Widget', description: 'Open the interactive VAT review widget for a period. Equivalent to gnubok_get_vat_report(render_ui=true); kept as an alias for clients pinned to this tool name.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' }, year: { type: 'number', description: 'Year (e.g. 2025)' }, period: { type: 'number', description: '1-12 for monthly, 1-4 for quarterly, 1 for yearly' }, }, required: ['period_type', 'year', 'period'], }, outputSchema: VAT_REPORT_OUTPUT_SCHEMA, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, _meta: { ui: { resourceUri: 'ui://vat-review/app.html' } }, async execute(args, companyId, _userId, supabase) { return computeVatReport(args, companyId, supabase) }, }, { name: 'gnubok_vat_close_check', title: 'VAT Close Check (Momsdeklaration)', description: "Answer 'can I close VAT?' in one call. Returns SKV 4700 rutor, blockers (uncategorized, unapproved supplier invoices, reconciliation diff, missing receipts) plus declaration_checks: the momsdeklaration completeness gate the web filing UI uses. ready_to_close covers both.", inputSchema: { type: 'object', additionalProperties: false, properties: { period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' }, year: { type: 'number', description: 'Year (e.g. 2026)' }, period: { type: 'number', description: '1-12 for monthly, 1-4 for quarterly, 1 for yearly' }, }, required: ['period_type', 'year', 'period'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { period: { type: 'object' }, period_label: { type: 'string' }, rutor: { type: 'object' }, payment: { type: 'object', properties: { net_due: { type: 'number' }, direction: { type: 'string', enum: ['pay', 'refund', 'zero'] }, deadline: { type: ['string', 'null'] }, deadline_label: { type: ['string', 'null'] }, moms_period: { type: ['string', 'null'] }, }, }, blockers: { type: 'array', items: { type: 'object' } }, declaration_checks: { type: 'array', items: { type: 'object' }, description: 'Completeness findings: { code, status, message, rutor }. Any ERROR forces ready_to_close=false.', }, sanity: { type: 'object' }, ready_to_close: { type: 'boolean' }, summary: { type: 'string' }, }, required: ['period', 'rutor', 'payment', 'blockers', 'declaration_checks', 'sanity', 'ready_to_close', 'summary'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { return computeVatCloseCheck(args, companyId, supabase) }, }, // ── KPI & Income Statement tools ───────────────────────────── { name: 'gnubok_get_kpi_report', title: 'Business KPI Report', description: 'Business KPIs for a fiscal period: gross margin, net result, cash position, receivables, expense ratio, payment days, VAT liability, monthly trend.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) { throw new Error('No fiscal periods found. Categorize some transactions first.') } periodId = periods.id } // Verify period belongs to user const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', periodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found.') // Run queries in parallel (same as the KPI API route) const [incomeStatement, trialBalance, arLedger, monthlyBreakdown, paidInvoices] = await Promise.all([ generateIncomeStatement(supabase, companyId, periodId!), generateTrialBalance(supabase, companyId, periodId!, { closingEntry: 'include' }), generateARLedger(supabase, companyId), generateMonthlyBreakdown(supabase, companyId, periodId!), supabase .from('invoices') .select('invoice_date, paid_at') .eq('company_id', companyId) .eq('status', 'paid') .not('paid_at', 'is', null), ]) const grossMargin = calculateGrossMargin(incomeStatement) const cashPosition = calculateCashPosition(trialBalance.rows) const expenseRatio = calculateExpenseRatio(incomeStatement) const avgPaymentDays = calculateAvgPaymentDays( (paidInvoices.data ?? []) as { invoice_date: string; paid_at: string }[] ) // AR ledger uses entries, each with invoices that have outstanding amounts const outstandingReceivables = arLedger.total_outstanding const overdueReceivables = arLedger.total_overdue // VAT liability from trial balance (same accounts as momsdeklaration ruta 49) const vatLiability = calculateVatLiability(trialBalance.rows) return { period_name: period.name, period_start: period.period_start, period_end: period.period_end, gross_margin: grossMargin, net_result: incomeStatement.net_result, cash_position: cashPosition, outstanding_receivables: Math.round(outstandingReceivables * 100) / 100, overdue_receivables: Math.round(overdueReceivables * 100) / 100, expense_ratio: expenseRatio, avg_payment_days: avgPaymentDays, paid_invoice_count: paidInvoices.data?.length ?? 0, vat_liability: vatLiability, total_revenue: incomeStatement.total_revenue, total_expenses: incomeStatement.total_expenses, months: monthlyBreakdown.months, } }, }, { name: 'gnubok_get_income_statement', title: 'Income Statement (Resultaträkning)', description: 'Income statement (resultaträkning) for a fiscal period: revenue, expenses, net result by account category. Optional dimensions filter scopes to tagged lines (kostnadsställe/projekt).', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA, }, }, outputSchema: { type: 'object', properties: { ...DIMENSION_FILTER_OUTPUT_PROPS }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) { throw new Error('No fiscal periods found. Categorize some transactions first.') } periodId = periods.id } const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', periodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found.') const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions) const result = await generateIncomeStatement( supabase, companyId, periodId!, dimFilter.filter ? { dimensions: dimFilter.filter } : undefined, ) result.period = { start: period.period_start, end: period.period_end } return { period_name: period.name, ...result, ...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}), ...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}), } }, }, // ── Invoice Operations ─────────────────────────────────────── { name: 'gnubok_mark_invoice_as_paid', title: 'Mark Invoice as Paid', description: 'Mark an invoice as paid and create the payment journal entry. Stages for approval. Status must be sent or overdue.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the invoice' }, payment_date: { type: 'string', description: 'Payment date YYYY-MM-DD (default: today)' }, allow_duplicate: { type: 'boolean', description: 'Override the duplicate-payment guard (default false). Set true ONLY after the user confirms; the guard blocks marking paid when an unlinked bank transaction already looks like this invoice\'s payment: match that transaction instead.' }, }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invoiceError || !invoice) throw new Error('Invoice not found') if (invoice.status !== 'sent' && invoice.status !== 'overdue') { throw new Error('Invoice can only be marked as paid when status is "sent" or "overdue"') } const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0] // Duplicate-payment guard: surface a likely existing bank payment to the // agent before staging, so it matches the transaction to the invoice // instead of booking a parallel payment voucher (the orphan that later // double-counts the receipt). The commit executor re-checks as the hard // gate. Mirrors the web mark-paid route's guard. if (args.allow_duplicate !== true && invoice.customer?.name) { const remainingAmount = (invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId, invoice: { invoice_number: invoice.invoice_number, customer_name: invoice.customer.name, currency: invoice.currency ?? null, total: invoice.total ?? null, total_sek: invoice.total_sek ?? null, exchange_rate: invoice.exchange_rate ?? null, }, // remaining_amount is stored in the invoice currency; the lookup // converts it before banding kronor bank rows. paymentAmount: remainingAmount, paymentDate, }) if (candidates.length > 0) { throw new Error( `Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` + `${invoice.invoice_number}. Matcha banktransaktionen mot fakturan med gnubok_match_transaction_to_invoice ` + `i stället. Anropa igen med allow_duplicate=true om det verkligen är en separat betalning.`, ) } } return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_paid', `Betald: ${invoice.invoice_number} ${invoice.customer?.name || ''} ${invoice.total} ${invoice.currency}`, { invoice_id: invoiceId, payment_date: paymentDate, allow_duplicate: args.allow_duplicate === true }, { invoice_number: invoice.invoice_number, customer_name: invoice.customer?.name, total: invoice.total, currency: invoice.currency, payment_date: paymentDate, }, actor, { description: 'Once approved, the payment is booked (15xx → 19xx). Use gnubok_get_ar_ledger to confirm the customer balance reflects it.', tool: 'gnubok_get_ar_ledger', }, { dateForPeriodCheck: paymentDate }, ) }, }, { name: 'gnubok_send_invoice', title: 'Send Invoice by Email', description: 'Send invoice via email with PDF attachment. Stages for approval. Requires customer email + email service configured.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the invoice to send' }, }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const emailService = getEmailService() if (!emailService.isConfigured()) { throw new Error('Email service not configured. Ensure RESEND_API_KEY and RESEND_FROM_EMAIL are set.') } const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invoiceError || !invoice) throw new Error('Invoice not found') const customer = invoice.customer as Customer if (!customer.email) throw new Error('Customer has no email address. Update customer details first.') return stagePendingOperation(supabase, companyId, userId, 'send_invoice', `Skicka: ${invoice.invoice_number} till ${customer.email}`, { invoice_id: invoiceId }, { invoice_number: invoice.invoice_number, customer_name: customer.name, customer_email: customer.email, total: invoice.total, currency: invoice.currency, }, actor, { description: 'After the customer pays, mark the invoice paid via gnubok_mark_invoice_as_paid (or match it to the inbound bank transaction with gnubok_match_transaction_to_invoice).', tool: 'gnubok_mark_invoice_as_paid', args: { invoice_id: invoiceId }, } ) }, }, { name: 'gnubok_get_invoice_deliveries', title: 'Get Invoice Delivery History', description: 'Email delivery attempts for one invoice with the provider outcome (delivered, bounced, complained, delayed, suppressed). Call before chasing an unpaid invoice: a bounce means the customer never received it. Recipients are masked, message content is never returned.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the invoice whose delivery attempts to list' }, }, required: ['invoice_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { deliveries: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { invoice_delivery_id: { type: 'string' }, channel: { type: 'string', description: 'email or manual' }, status: { type: 'string', description: 'Our own send state: pending, sent, failed, marked_sent' }, provider: { type: ['string', 'null'] }, provider_status: { type: ['string', 'null'], description: 'What the receiving server did. NULL means no report yet: accepted by the provider, nothing more.', }, provider_status_at: { type: ['string', 'null'] }, provider_status_detail: { type: ['string', 'null'], description: 'Provider reason text for a failure, with address local parts masked.', }, provider_recipient_statuses: { type: 'object', description: 'PII-free outcomes keyed by stable To/CC positions such as to:1 and cc:1. BCC is never included.', additionalProperties: { type: 'object', additionalProperties: false, properties: { status: { type: 'string' }, status_at: { type: 'string' }, }, required: ['status', 'status_at'], }, }, error_code: { type: ['string', 'null'] }, to_addresses: { type: 'array', items: { type: 'string' }, description: 'Masked to ***@domain: the domain is enough to spot a wrong recipient.', }, cc_addresses: { type: 'array', items: { type: 'string' } }, attachment_filename: { type: ['string', 'null'] }, sent_at: { type: ['string', 'null'] }, failed_at: { type: ['string', 'null'] }, created_at: { type: 'string' }, }, required: ['invoice_delivery_id', 'channel', 'status', 'created_at'], }, }, count: { type: 'number' }, }, required: ['deliveries', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, // Specialized per-invoice diagnostic: keep it out of the default tools/list // (context budget, payload-size.bench.test.ts) and let agents chasing an // unpaid invoice find it via gnubok_search_tools (delivery, bounce, email). catalogVisibility: 'search', async execute(args, companyId, userId, supabase) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('id') .eq('id', invoiceId) .eq('company_id', companyId) .maybeSingle() if (invoiceError) throw new Error(`Database error: ${invoiceError.message}`) if (!invoice) throw new Error('Invoice not found') // Never read invoice_deliveries directly: the row carries the exact // subject, body and BCC of a customer mail. The RPC is the masking // boundary (migration 20260727100000), and it is the service-role // sibling because MCP has no auth.uid() and routes to the API key's // company rather than the user's active one. const { data, error } = await supabase.rpc( 'list_invoice_delivery_summaries_for_service', { p_company_id: companyId, p_user_id: userId, p_invoice_id: invoiceId }, ) if (error) throw new Error(`Database error: ${error.message}`) // Mirrors the RETURNS TABLE of the RPC. body_html, body_text and // bcc_addresses are absent by construction, not filtered here. const rows = (data ?? []) as Array<{ id: string channel: string status: string to_addresses: string[] | null cc_addresses: string[] | null provider: string | null provider_status: string | null provider_status_at: string | null provider_status_detail: string | null provider_recipient_statuses: Record | null error_code: string | null attachment_filename: string | null sent_at: string | null failed_at: string | null created_at: string }> const deliveries = rows.map((row) => ({ invoice_delivery_id: row.id, channel: row.channel, status: row.status, provider: row.provider ?? null, provider_status: row.provider_status ?? null, provider_status_at: row.provider_status_at ?? null, provider_status_detail: row.provider_status_detail ?? null, provider_recipient_statuses: sanitizeDeliveryRecipientStatuses( row.provider_recipient_statuses, ), error_code: row.error_code ?? null, to_addresses: row.to_addresses ?? [], cc_addresses: row.cc_addresses ?? [], attachment_filename: row.attachment_filename ?? null, sent_at: row.sent_at ?? null, failed_at: row.failed_at ?? null, created_at: row.created_at, })) return { deliveries, count: deliveries.length } }, }, { name: 'gnubok_mark_invoice_as_sent', title: 'Mark Invoice as Sent', description: 'Mark a draft invoice as sent without sending email (when delivered manually). Stages for approval. Status must be draft.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the draft invoice' }, }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invoiceError || !invoice) throw new Error('Invoice not found') if (invoice.status !== 'draft') throw new Error('Only draft invoices can be marked as sent') return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_sent', `Markera skickad: ${invoice.invoice_number} ${invoice.customer?.name || ''}`, { invoice_id: invoiceId }, { invoice_number: invoice.invoice_number, customer_name: invoice.customer?.name, total: invoice.total, currency: invoice.currency, }, actor, { description: 'Once approved, the invoice moves to "sent". Track its payment via gnubok_mark_invoice_as_paid when the customer pays.', tool: 'gnubok_mark_invoice_as_paid', args: { invoice_id: invoiceId }, } ) }, }, // ── Supplier Operations (Read-Only) ────────────────────────── { name: 'gnubok_list_suppliers', title: 'List Suppliers (Leverantörer)', description: 'List all suppliers (leverantörer) with contact and payment details, sorted by name.', inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', additionalProperties: false, properties: { suppliers: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['suppliers', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { const { data, error } = await supabase .from('suppliers') .select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country') .eq('company_id', companyId) .order('name', { ascending: true }) if (error) throw new Error(`Database error: ${error.message}`) return { suppliers: data ?? [], count: data?.length ?? 0 } }, }, { name: 'gnubok_create_supplier', title: 'Create Supplier (Leverantör)', description: 'Stage a new supplier (leverantör). Stages for user approval: NOT created until approved in the web app. Use to add a vendor before booking a supplier invoice or matching expenses.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { name: { type: 'string', maxLength: 255, description: 'Supplier name' }, supplier_type: { type: 'string', enum: ['swedish_business', 'eu_business', 'non_eu_business'], description: 'Supplier type (default swedish_business). eu_business requires vat_number.', }, email: { type: 'string', maxLength: 255, format: 'email', description: 'Email address' }, phone: { type: 'string', maxLength: 50, description: 'Phone number' }, org_number: { type: 'string', maxLength: 20, pattern: '^\\d{6}-?\\d{4}$|^\\d{12}$', description: 'Swedish org number (10 digits with optional hyphen XXXXXX-XXXX, or 12 digits).', }, vat_number: { type: 'string', maxLength: 20, description: 'EU VAT number with country prefix (e.g. SE556677778800, DE123456789). Required when supplier_type is eu_business.', }, address_line1: { type: 'string', maxLength: 255, description: 'Street address' }, address_line2: { type: 'string', maxLength: 255 }, postal_code: { type: 'string', maxLength: 20 }, city: { type: 'string', maxLength: 100 }, country: { type: 'string', maxLength: 2, pattern: '^[A-Za-z]{2}$', description: 'ISO 3166-1 alpha-2 country code (default SE)', }, bankgiro: { type: 'string', maxLength: 20, pattern: '^\\d{3,4}-?\\d{4}$', description: 'Swedish Bankgiro number (7-8 digits with valid Luhn check digit).', }, plusgiro: { type: 'string', maxLength: 20, pattern: '^\\d{1,7}-?\\d{1}$', description: 'Swedish Plusgiro number (2-8 digits).', }, bank_account: { type: 'string', maxLength: 50, description: 'Bank account number' }, iban: { type: 'string', maxLength: 34, pattern: '^[A-Z]{2}\\d{2}[A-Z0-9]{11,30}$', description: 'IBAN (ISO 13616). Country code + 2 check digits + alphanumeric.', }, bic: { type: 'string', maxLength: 11, pattern: '^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$', description: 'BIC/SWIFT code (8 or 11 chars).', }, default_expense_account: { type: 'string', maxLength: 10, pattern: '^[4567]\\d{3}$', description: '4-digit BAS expense account (class 4, 5, 6, or 7). e.g. "5010".', }, default_payment_terms: { type: 'integer', minimum: 0, maximum: 365, description: 'Payment terms in days (default 30). Use 0 for due-on-receipt.', }, default_currency: { type: 'string', minLength: 3, maxLength: 3, description: 'Default invoice currency, 3-letter ISO code (default SEK).', }, notes: { type: 'string', maxLength: 2000 }, dry_run: { type: 'boolean', description: 'If true, validate inputs and return the would-be preview without staging or creating. No DB writes, no side-effects.', }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.', }, }, required: ['name'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { // Server-side validation (defense in depth): MCP transport already // checks the JSON Schema, but we re-validate with Zod so financial // identifiers (IBAN, BIC, bankgiro Luhn, org_number, VAT format) are // rejected at the ingestion boundary rather than persisted. // Strip MCP control fields before parsing: the strict schema rejects // unknown keys to satisfy ASVS V4.5 field-allow-listing. const { dry_run, idempotency_key, ...supplierArgs } = args let params try { params = CreateSupplierParamsSchema.parse(supplierArgs) } catch (err) { if (err instanceof z.ZodError) { const issue = err.issues[0] const path = issue?.path?.join('.') ?? 'params' throw new Error(`Invalid ${path}: ${issue?.message ?? 'validation failed'}`) } throw err } return stagePendingOperation(supabase, companyId, userId, 'create_supplier', `Ny leverantör: ${params.name}`, params, params, actor, { description: 'Once approved, you can book supplier invoices against this supplier with gnubok_create_supplier_invoice_from_inbox using the returned supplier_id.', tool: 'gnubok_create_supplier_invoice_from_inbox', }, { dryRun: Boolean(dry_run), idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined, } ) }, }, { name: 'gnubok_list_supplier_invoices', title: 'List Supplier Invoices', description: 'List supplier invoices (leverantörsfakturor), sorted by due date. Optional status filter; "to_pay" combines approved+overdue.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', description: 'Filter: registered, approved, overdue, paid, to_pay, all (default)', enum: ['registered', 'approved', 'overdue', 'paid', 'to_pay', 'all'], }, limit: { type: 'number', description: 'Max results 1-100 (default 50)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { invoices: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['invoices', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100) const status = (args.status as string) || 'all' let query = supabase .from('supplier_invoices') .select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, default_dimensions, supplier:suppliers(id, name)') .eq('company_id', companyId) if (status !== 'all') { if (status === 'to_pay') { query = query.in('status', ['approved', 'overdue']) } else { query = query.eq('status', status) } } const { data, error } = await query.order('due_date', { ascending: true }).limit(limit) if (error) throw new Error(`Database error: ${error.message}`) return { invoices: data ?? [], count: data?.length ?? 0 } }, }, // ── Counterparty Templates & Suggestions ───────────────────── { name: 'gnubok_get_counterparty_templates', title: 'List Counterparty Templates', description: 'List active counterparty categorization templates: learned patterns from prior categorizations used for auto-matching new transactions.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results 1-200 (default 100)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { templates: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['templates', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 100), 200) const { data, error } = await supabase .from('categorization_templates') .select('id, counterparty_name, counterparty_aliases, debit_account, credit_account, vat_treatment, vat_account, category, line_pattern, occurrence_count, confidence, last_seen_date, source') .eq('company_id', companyId) .eq('is_active', true) .order('occurrence_count', { ascending: false }) .limit(limit) if (error) throw new Error(`Database error: ${error.message}`) return { templates: (data ?? []).map((t) => ({ ...t, counterparty_name_display: formatCounterpartyName(t.counterparty_name), })), count: data?.length ?? 0, } }, }, { name: 'gnubok_suggest_categories', title: 'Suggest Transaction Categories', description: 'Suggest categories for uncategorized transactions using mapping rules, patterns, counterparty history and templates. Up to 20 per call. no_signal_transaction_ids = nothing matched; investigate via gnubok_query_journal instead of guessing.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_ids: { type: 'array', items: { type: 'string' }, description: 'Up to 20 transaction UUIDs', }, }, required: ['transaction_ids'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { suggestions: { type: 'object' }, counterparty_matches: { type: 'object' }, no_signal_transaction_ids: { type: 'array', items: { type: 'string' }, description: 'Transactions where no source (rule, pattern, counterparty history, template) matched. An honest empty: do not infer categories from the other rows; investigate the counterparty (e.g. gnubok_query_journal) instead.', }, }, required: ['suggestions', 'counterparty_matches', 'no_signal_transaction_ids'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const ids = args.transaction_ids as string[] if (!ids || ids.length === 0) throw new Error('transaction_ids is required (non-empty array)') const limitedIds = ids.slice(0, 20) // Fetch transactions const { data: transactions, error: txError } = await supabase .from('transactions') .select('*') .eq('company_id', companyId) .in('id', limitedIds) if (txError) throw new Error(`Database error: ${txError.message}`) if (!transactions || transactions.length === 0) throw new Error('No transactions found') // Fetch mapping rules const { data: mappingRules } = await supabase .from('mapping_rules') .select('*') .or(`company_id.eq.${companyId},company_id.is.null`) .eq('is_active', true) .order('priority', { ascending: false }) // Build category history from past categorizations // Counterparty-keyed history: the engine only surfaces history tied to // the SAME merchant: global frequency padding produced the identical // ~0.5 four-way spread agents reported as pure noise (P2-1). const { data: historicalTxns } = await supabase .from('transactions') .select('category, merchant_name, description, original_description') .eq('company_id', companyId) .not('is_business', 'is', null) .neq('category', 'uncategorized') .neq('category', 'private') .order('date', { ascending: false }) .limit(200) const merchantHistory = buildMerchantHistory(historicalTxns ?? []) // Batch counterparty template matching const counterpartyMatches = await findCounterpartyTemplatesBatch( supabase, companyId, transactions as Transaction[] ) // Generate suggestions per transaction const suggestions: Record = {} const counterpartyResult: Record = {} for (const tx of transactions) { suggestions[tx.id] = getSuggestedCategories( tx as Transaction, mappingRules ?? [], merchantHistoryFor( merchantHistory, (tx as Transaction).merchant_name, (tx as Transaction).original_description ?? (tx as Transaction).description, ) ) const cpMatch = counterpartyMatches.get(tx.id) if (cpMatch) { counterpartyResult[tx.id] = { template_name: formatCounterpartyName(cpMatch.template.counterparty_name), debit_account: cpMatch.template.debit_account, credit_account: cpMatch.template.credit_account, category: cpMatch.template.category, confidence: cpMatch.confidence, match_method: cpMatch.matchMethod, occurrence_count: cpMatch.template.occurrence_count, } } } // Honest absence beats fabricated confidence: mark transactions where // NO source produced a suggestion so agents investigate instead of // pattern-matching on unrelated rows (P2-1). const noSignal = transactions .filter((tx) => (suggestions[tx.id]?.length ?? 0) === 0 && !counterpartyResult[tx.id]) .map((tx) => tx.id) return { suggestions, counterparty_matches: counterpartyResult, no_signal_transaction_ids: noSignal, } }, }, // ── Accounts & Chart of Accounts ───────────────────────────── { name: 'gnubok_list_accounts', title: 'List Chart of Accounts (Kontoplan)', description: 'List chart of accounts (kontoplan). account_class: 1=assets, 2=liabilities, 3=revenue, 4-7=expenses, 8=financial.', inputSchema: { type: 'object', additionalProperties: false, properties: { account_class: { type: 'number', description: 'Filter by class (1-8)' }, active_only: { type: 'boolean', description: 'Only active accounts (default: true)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { accounts: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['accounts', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const activeOnly = args.active_only !== false const accountClass = args.account_class as number | undefined let query = supabase .from('chart_of_accounts') .select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description') .eq('company_id', companyId) .order('sort_order') if (activeOnly) query = query.eq('is_active', true) if (accountClass !== undefined) query = query.eq('account_class', accountClass) const { data, error } = await query if (error) throw new Error(`Database error: ${error.message}`) return { accounts: data ?? [], count: data?.length ?? 0 } }, }, { name: 'gnubok_create_account', title: 'Create Account (Kontoplan)', description: 'Stage a new kontoplan account. BAS 2026 numbers prefill name/type/SRU (overrides win); custom numbers need account_name, account_type, normal_balance. Inactive existing account? Use gnubok_update_account instead.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { account_number: { type: 'string', description: '4-digit number, e.g. "5410".' }, account_name: { type: 'string', description: 'Optional for BAS numbers (prefilled).' }, account_type: { type: 'string', enum: ['asset', 'equity', 'liability', 'revenue', 'expense', 'untaxed_reserves'], description: 'Required for non-BAS numbers. untaxed_reserves only for 21xx (obeskattade reserver).', }, normal_balance: { type: 'string', enum: ['debit', 'credit'], description: 'Required for non-BAS numbers.', }, description: { type: 'string' }, default_vat_code: { type: 'string' }, default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' }, sru_code: { type: 'string', description: 'Prefilled for BAS numbers.' }, dry_run: { type: 'boolean', description: 'Validate and preview without staging.' }, idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' }, }, required: ['account_number'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const accountNumber = String(args.account_number ?? '').trim() if (!/^\d{4}$/.test(accountNumber)) { throw new Error('account_number must be exactly 4 digits, e.g. "5410".') } // Fail fast on numbers already in this company's chart so the approver // is never shown a create that would 409 at commit time. const { data: existing, error: existingErr } = await supabase .from('chart_of_accounts') .select('account_number, account_name, is_active') .eq('company_id', companyId) .eq('account_number', accountNumber) .maybeSingle() if (existingErr) throw new Error(`Database error: ${existingErr.message}`) if (existing) { throw new Error( existing.is_active ? `Konto ${accountNumber} (${existing.account_name}) finns redan i kontoplanen. Ändra det med gnubok_update_account.` : `Konto ${accountNumber} (${existing.account_name}) finns men är inaktivt. Aktivera det med gnubok_update_account (is_active=true).`, ) } // Resolve-don't-guess: BAS 2026 catalog fills the gaps; explicit args win. const ref = getBASReference(accountNumber) const name = String(args.account_name ?? '').trim() || ref?.account_name const accountType = (args.account_type as string | undefined) ?? ref?.account_type const normalBalance = (args.normal_balance as string | undefined) ?? ref?.normal_balance if (!name || !accountType || !normalBalance) { throw new Error( `${accountNumber} is not in the BAS 2026 catalog: account_name, account_type and normal_balance are required for custom accounts.`, ) } // Runtime guard (hosts don't always enforce inputSchema enums). if (!['asset', 'equity', 'liability', 'revenue', 'expense', 'untaxed_reserves'].includes(accountType)) { throw new Error('account_type must be one of: asset, equity, liability, revenue, expense, untaxed_reserves') } if (!['debit', 'credit'].includes(normalBalance)) { throw new Error('normal_balance must be debit or credit') } // Fail fast on a class/type contradiction (e.g. 2999 + expense): the // commit executor derives account_class from the first digit, so an // inconsistent pair would misclassify balance sheet vs income statement. const classConflict = accountClassTypeConflict(accountNumber, accountType) if (classConflict) throw new Error(classConflict) const vatRate = args.default_vat_rate as number | undefined if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) { throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)') } const params: Record = { account_number: accountNumber, account_name: name, account_type: accountType, normal_balance: normalBalance, plan_type: ref ? 'full_bas' : 'k1', description: String(args.description ?? '').trim() || ref?.description || undefined, default_vat_code: String(args.default_vat_code ?? '').trim() || undefined, default_vat_rate: vatRate, sru_code: String(args.sru_code ?? '').trim() || ref?.sru_code || undefined, } return stagePendingOperation(supabase, companyId, userId, 'create_account', `Nytt konto: ${accountNumber} ${name}`, params, { ...params, source: ref ? 'bas_2026' : 'custom' }, actor, { description: 'Once approved, the account is active and can carry voucher lines via gnubok_create_voucher or gnubok_categorize_transaction.', tool: 'gnubok_list_accounts', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, } ) }, }, { name: 'gnubok_update_account', title: 'Update Account (Kontoplan)', description: 'Stage an edit to a kontoplan account: rename, description, default VAT, SRU code, or activate/deactivate via is_active. Stages for approval. Find accounts with gnubok_list_accounts.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { account_number: { type: 'string', description: '4-digit number of the account to update.' }, account_name: { type: 'string' }, description: { type: 'string' }, default_vat_code: { type: 'string' }, default_vat_rate: { type: 'number', enum: [0, 0.06, 0.12, 0.25], description: 'Default VAT rate as a fraction (0.25 = 25%). Livsmedel: 0.06 from 2026-04-01 (temporary cut from 0.12, reverts 2027-12-31).' }, sru_code: { type: 'string' }, is_active: { type: 'boolean', description: 'false deactivates (hides from pickers, keeps history); true (re)activates.' }, dry_run: { type: 'boolean' }, idempotency_key: { type: 'string' }, }, required: ['account_number'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const accountNumber = String(args.account_number ?? '').trim() if (!/^\d{4}$/.test(accountNumber)) { throw new Error('account_number must be exactly 4 digits, e.g. "5410".') } const { data: current, error: fetchErr } = await supabase .from('chart_of_accounts') .select('account_number, account_name, description, default_vat_code, default_vat_rate, sru_code, is_active') .eq('company_id', companyId) .eq('account_number', accountNumber) .maybeSingle() if (fetchErr) throw new Error(`Database error: ${fetchErr.message}`) if (!current) { throw new Error(`Konto ${accountNumber} finns inte i kontoplanen. Skapa det med gnubok_create_account.`) } const vatRate = args.default_vat_rate as number | undefined if (vatRate !== undefined && ![0, 0.06, 0.12, 0.25].includes(vatRate)) { throw new Error('default_vat_rate must be one of 0, 0.06, 0.12, 0.25 (fraction, not percent)') } const params: Record = { account_number: accountNumber } const changes: Record = {} for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'sru_code', 'is_active']) { if (args[key] !== undefined) { params[key] = args[key] changes[key] = args[key] } } if (Object.keys(changes).length === 0) { throw new Error('Nothing to update: pass at least one of account_name, description, default_vat_code, default_vat_rate, sru_code, is_active.') } return stagePendingOperation(supabase, companyId, userId, 'update_account', `Uppdatera konto ${accountNumber} ${current.account_name}`, params, { account_number: accountNumber, current, changes }, actor, undefined, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, } ) }, }, // ── Dimensions (kostnadsställe/projekt) ────────────────────── { name: 'gnubok_list_dimensions', title: 'List Dimensions (Kostnadsställe/Projekt)', description: 'List the dimension registry with values: 1 = kostnadsställe, 6 = projekt, plus custom dims. Call before tagging voucher lines via the dimensions bag on gnubok_create_voucher. System dims are seeded on first call.', inputSchema: { type: 'object', additionalProperties: false, properties: {}, }, outputSchema: { type: 'object', additionalProperties: false, properties: { dimensions: { type: 'array', description: 'Registry entries keyed by sie_dim_no (the dims-bag key), each with its values. code = what goes in the bag; is_active false = archived (unusable on new lines).', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read dimension_id instead' }, dimension_id: { type: 'string' }, sie_dim_no: { type: 'number' }, name: { type: 'string' }, resets_annually: { type: 'boolean' }, is_system: { type: 'boolean' }, is_active: { type: 'boolean' }, sort_order: { type: 'number' }, values: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read dimension_value_id instead' }, dimension_value_id: { type: 'string' }, code: { type: 'string' }, name: { type: 'string' }, is_active: { type: 'boolean' }, start_date: { type: ['string', 'null'] }, end_date: { type: ['string', 'null'] }, }, required: ['id', 'dimension_value_id', 'code', 'name', 'is_active', 'start_date', 'end_date'], }, }, }, required: ['id', 'dimension_id', 'sie_dim_no', 'name', 'resets_annually', 'is_system', 'is_active', 'sort_order', 'values'], }, }, }, required: ['dimensions'], }, annotations: { // The lazy ensure_company_dimensions seed is an idempotent get-or-create // of the two system registry rows: semantically a read (the dashboard // GET /api/dimensions does the same). readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, companyId, _userId, supabase) { await ensureCompanyDimensions(supabase, companyId) const dimensions = await fetchDimensionRegistry(supabase, companyId) return { dimensions: dimensions.map((d) => ({ ...d, dimension_id: d.id, values: d.values.map((v) => ({ ...v, dimension_value_id: v.id })), })), } }, }, { name: 'gnubok_list_dimension_values', title: 'List Dimension Values', description: 'List values (SIE #OBJEKT codes) for one dimension, optionally fuzzy-matched by query. Use to find the right kostnadsställe/projekt code before tagging lines. sie_dim_no: 1 = kostnadsställe, 6 = projekt.', inputSchema: { type: 'object', additionalProperties: false, properties: { sie_dim_no: { type: 'number', description: '1 = kostnadsställe, 6 = projekt, or a custom dim from gnubok_list_dimensions.' }, query: { type: 'string', description: 'Optional fuzzy search over code + name, ranked by confidence.' }, include_inactive: { type: 'boolean', description: 'Include archived values (default false).' }, limit: { type: 'number', description: 'Max results, 1-200 (default 50).' }, }, required: ['sie_dim_no'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { dimension: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read dimension_id instead' }, dimension_id: { type: 'string' }, sie_dim_no: { type: 'number' }, name: { type: 'string' }, resets_annually: { type: 'boolean' }, is_active: { type: 'boolean' }, }, required: ['id', 'dimension_id', 'sie_dim_no', 'name', 'resets_annually', 'is_active'], }, values: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { id: { type: 'string', description: 'Deprecated: read dimension_value_id instead' }, dimension_value_id: { type: 'string' }, code: { type: 'string' }, name: { type: 'string' }, is_active: { type: 'boolean' }, start_date: { type: ['string', 'null'] }, end_date: { type: ['string', 'null'] }, confidence: { type: 'number', description: 'Fuzzy confidence 0-1; present only with query.' }, }, required: ['id', 'dimension_value_id', 'code', 'name', 'is_active', 'start_date', 'end_date'], }, }, count: { type: 'number' }, }, required: ['dimension', 'values', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const sieDimNo = Number(args.sie_dim_no) if (!Number.isInteger(sieDimNo) || sieDimNo < 1) { throw new Error('sie_dim_no must be a positive integer SIE dimension number (1 = kostnadsställe, 6 = projekt).') } const includeInactive = args.include_inactive === true const limit = Math.min(Math.max(1, Number(args.limit) || 50), 200) const query = typeof args.query === 'string' ? args.query.trim() : '' await ensureCompanyDimensions(supabase, companyId) const { data: dimension, error: dimError } = await supabase .from('dimensions') .select('id, sie_dim_no, name, resets_annually, is_active') .eq('company_id', companyId) .eq('sie_dim_no', sieDimNo) .maybeSingle() if (dimError) throw new Error(`Database error: ${dimError.message}`) if (!dimension) { throw new Error( `Dimension ${sieDimNo} finns inte i registret. Anropa gnubok_list_dimensions för att se registrerade dimensioner.`, ) } let valuesQuery = supabase .from('dimension_values') .select('id, code, name, is_active, start_date, end_date') .eq('company_id', companyId) .eq('dimension_id', dimension.id) .order('code', { ascending: true }) if (!includeInactive) valuesQuery = valuesQuery.eq('is_active', true) const { data: rows, error: valuesError } = await valuesQuery if (valuesError) throw new Error(`Database error: ${valuesError.message}`) const all = (rows ?? []) as Array<{ id: string code: string name: string is_active: boolean start_date: string | null end_date: string | null }> const qualifiedDimension = { ...dimension, dimension_id: dimension.id } if (!query) { const values = all.slice(0, limit).map((v) => ({ ...v, dimension_value_id: v.id })) return { dimension: qualifiedDimension, values, count: values.length } } // Fuzzy ranking: same fuse.js setup as the resolve step so what this // tool shows matches what a dims bag would resolve to. const fuse = new Fuse(all, { keys: ['code', 'name'], includeScore: true, threshold: 0.4 }) const values = fuse .search(query) .slice(0, limit) .map((hit) => ({ ...hit.item, dimension_value_id: hit.item.id, confidence: roundOre(1 - (hit.score ?? 1)), })) return { dimension: qualifiedDimension, values, count: values.length } }, }, { name: 'gnubok_create_dimension_value', title: 'Create Dimension Value', description: 'Stage a new dimension value (kostnadsställe/projekt object code, SIE #OBJEKT) for user approval: agents never silently mint reporting values. Use when a dims-bag value has no registry match. sie_dim_no: 1 = kostnadsställe, 6 = projekt.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { sie_dim_no: { type: 'number', description: '1 = kostnadsställe, 6 = projekt, or a custom dim.' }, code: { type: 'string', maxLength: 20, pattern: '^[A-Za-z0-9\\u00C5\\u00C4\\u00D6\\u00E5\\u00E4\\u00F6_+\\-]{1,20}$', description: 'Object code, strict Fortnox format: letters A-Ö, digits, _, + and -. Immutable after creation.', }, name: { type: 'string', maxLength: 120, description: 'Human-readable name shown in registers and reports.' }, start_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Optional ISO start date; only on accumulating dims (projekt).' }, end_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Optional ISO end date ≥ start_date; only on accumulating dims.' }, dry_run: { type: 'boolean', description: 'If true, validate inputs and return the would-be preview without staging. No DB writes, no side-effects.', }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.', }, }, required: ['sie_dim_no', 'code', 'name'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { // Strip MCP control fields, then re-validate with the same Zod schema // the commit executor uses (defense in depth, mirrors create_supplier). const { dry_run, idempotency_key, ...valueArgs } = args let params try { params = CreateDimensionValueParamsSchema.parse(valueArgs) } catch (err) { if (err instanceof z.ZodError) { const issue = err.issues[0] const path = issue?.path?.join('.') ?? 'params' throw new Error(`Invalid ${path}: ${issue?.message ?? 'validation failed'}`) } throw err } await ensureCompanyDimensions(supabase, companyId) // Pre-flight for a tight agent feedback loop; the executor re-checks all // of this at commit time (the staged row is never trusted). const { data: dimension, error: dimError } = await supabase .from('dimensions') .select('id, sie_dim_no, name, resets_annually') .eq('company_id', companyId) .eq('sie_dim_no', params.sie_dim_no) .maybeSingle() if (dimError) throw new Error(`Database error: ${dimError.message}`) if (!dimension) { throw new Error( `Okänd dimension ${params.sie_dim_no}. Endast registrerade dimensioner kan få nya värden: ` + 'anropa gnubok_list_dimensions (1 = kostnadsställe och 6 = projekt skapas automatiskt).', ) } if (dimension.resets_annually && (params.start_date || params.end_date)) { throw new Error( `Start-/slutdatum är inte tillåtna på dimensionen "${dimension.name}" (nollställs årligen).`, ) } const { data: existing, error: existingError } = await supabase .from('dimension_values') .select('id, code, name, is_active') .eq('company_id', companyId) .eq('dimension_id', dimension.id) .eq('code', params.code) .maybeSingle() if (existingError) throw new Error(`Database error: ${existingError.message}`) if (existing?.is_active) { throw new Error( `Värdet "${params.code}" (${existing.name}) finns redan i ${dimension.name}: använd koden direkt i dimensions-baggen.`, ) } if (existing) { throw new Error( `"${params.code}" är arkiverat: återaktivera värdet i registret för att använda det.`, ) } return stagePendingOperation(supabase, companyId, userId, 'create_dimension_value', `Nytt värde i ${dimension.name}: ${params.code} - ${params.name}`, params, { sie_dim_no: dimension.sie_dim_no, dimension_name: dimension.name, code: params.code, name: params.name, start_date: params.start_date ?? null, end_date: params.end_date ?? null, will: 'create the value in the dimension registry so lines can be tagged with it', }, actor, { description: 'Once approved, tag voucher lines with the new code via the dimensions bag on gnubok_create_voucher, or verify it with gnubok_list_dimension_values.', tool: 'gnubok_list_dimension_values', args: { sie_dim_no: dimension.sie_dim_no }, }, { dryRun: Boolean(dry_run), idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined, } ) }, }, { name: 'gnubok_tag_journal_lines', title: 'Tag Journal Lines (Bulk Retag)', description: "Bulk-tag POSTED journal lines with dimensions (kostnadsställe/projekt) selected by a filter block, e.g. all 4010 lines with 'Bygg AB' in 2024 → P01. Stages for approval; max 500 lines. Retags internal reporting only: the verifikat stays immutable, every change logged.", outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dimensions bag applied to every matched line, REPLACING its current bag: {"":""}, e.g. {"6":"P01"}. Values may be registry codes or names: resolved server-side (resolve-don\'t-select).', }, reason: { type: 'string', minLength: 3, maxLength: 500, description: 'Why the lines are retagged: stored per line in the immutable dimension_retag_log.', }, filters: { type: 'object', additionalProperties: false, description: 'Line selection: at least one filter required. Preview the match set with gnubok_query_journal (same filter fields) first.', properties: { account_from: { type: 'string', description: 'Lowest account number (inclusive), e.g. "4010".' }, account_to: { type: 'string', description: 'Highest account number (inclusive).' }, accounts: { type: 'array', items: { type: 'string' }, description: 'Specific account numbers (overrides account_from/account_to). Up to 50.' }, date_from: { type: 'string', description: 'Earliest entry date (YYYY-MM-DD, inclusive).' }, date_to: { type: 'string', description: 'Latest entry date (YYYY-MM-DD, inclusive).' }, text: { type: 'string', maxLength: 200, description: 'Case-insensitive substring match on the ENTRY description (verifikattext): line descriptions are not searched.' }, only_untagged: { type: 'boolean', description: 'Only lines whose dimensions bag is exactly empty ({}). Lines already carrying ANY dimension are excluded: partially tagged lines do not match.' }, }, }, dry_run: { type: 'boolean', description: 'If true, validate inputs and return the would-be preview without staging. No DB writes, no side-effects.', }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.', }, }, required: ['dimensions', 'reason', 'filters'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const { dry_run, idempotency_key } = args const reason = typeof args.reason === 'string' ? args.reason.trim() : '' if (reason.length < 3 || reason.length > 500) { throw new Error('reason must be 3-500 characters: it is stored in the immutable dimension_retag_log.') } const inputBag = parseDimensionsArg(args.dimensions, 'dimensions') if (!inputBag) { throw new Error('dimensions must contain at least one {"":""} pair, e.g. {"6":"P01"}.') } // ── Filters: validated before any DB work so bad input fails fast. const filters = (args.filters && typeof args.filters === 'object' ? args.filters : {}) as Record const accounts = Array.isArray(filters.accounts) ? (filters.accounts as string[]) : undefined if (accounts && accounts.length > 50) { throw new Error('filters.accounts is capped at 50: use account_from/account_to for ranges') } const accountFrom = typeof filters.account_from === 'string' ? filters.account_from : undefined const accountTo = typeof filters.account_to === 'string' ? filters.account_to : undefined const dateFrom = typeof filters.date_from === 'string' ? filters.date_from : undefined const dateTo = typeof filters.date_to === 'string' ? filters.date_to : undefined const text = typeof filters.text === 'string' ? filters.text.trim() : '' if (text.length > 200) { throw new Error('filters.text must be 200 characters or shorter') } const onlyUntagged = filters.only_untagged === true const hasFilter = Boolean( (accounts && accounts.length > 0) || accountFrom || accountTo || dateFrom || dateTo || text || onlyUntagged, ) if (!hasFilter) { throw new Error( 'Ange minst ett filter (konto, datum, text eller only_untagged): en företagsbred omtaggning måste avgränsas. ' + 'Förhandsgranska träffmängden med gnubok_query_journal.', ) } // ── Resolve the bag (names → registry codes; resolve-don't-select). // DimensionResolutionError propagates with candidates/create-first // guidance: nothing unresolved is ever staged. const { bags, resolutions } = await resolveDimensionBags(supabase, companyId, [inputBag]) const resolvedBag = bags[0] as Record // ── Match the lines. POSTED entries only: drafts are edited directly // (the retag RPC rejects them too). type MatchedRow = { id: string account_number: string debit_amount: number credit_amount: number sort_order: number journal_entries: { id: string; entry_date: string; voucher_number: number; voucher_series: string } } // Two-step fetch (lib/bookkeeping/entry-lines.ts) instead of a // `journal_entries!inner` embed, which PostgREST compiled into a // correlated LATERAL join over every tenant's journal_entry_lines. // The DB-side `.limit(RETAG_MAX_LINES + 1)` is replaced by the JS // overflow check below: the cap counts MATCHED lines, and the old // limit could not be expressed across the two steps. // // BOUNDED: entries are paged and their lines fetched chunk by chunk, // and the whole walk STOPS as soon as the accumulated line count // exceeds RETAG_MAX_LINES: the overflow check below then throws the // same ">500 rader" error either way. Without the short-circuit, // `only_untagged: true` alone (a valid filter per the guard above) on a // large ledger materialized every matching line first: hundreds of // round-trips just to throw. fetchLinesByEntryIds is the same shared // chunked helper fetchEntryLines drives; only the loop around it is // local so it can bail early. const filterEntries = (eq: EntryLinesQuery) => { let e = eq.eq('company_id', companyId).eq('status', 'posted') if (dateFrom) e = e.gte('entry_date', dateFrom) if (dateTo) e = e.lte('entry_date', dateTo) if (text) { // LIKE wildcards escaped so the filter matches literal % / _: // same treatment as gnubok_query_journal's text legs. v1 // searches the ENTRY description only (documented in the // schema); the two-leg line+entry union query_journal runs is // overkill for a write filter. // // Backslash is escaped FIRST, and the order matters: `\` is LIKE's // own escape character, so an unescaped one in the search term // swallows the character after it (searching `a\b` matched rows // containing `ab`). Escaping it last would instead double the // backslashes the % / _ rules just added. const escaped = text .replace(/\\/g, '\\\\') .replace(/%/g, '\\%') .replace(/_/g, '\\_') e = e.ilike('description', `%${escaped}%`) } return e } const filterLines = (lq: EntryLinesQuery) => { let l = lq if (accounts && accounts.length > 0) { l = l.in('account_number', accounts) } else { if (accountFrom) l = l.gte('account_number', accountFrom) if (accountTo) l = l.lte('account_number', accountTo) } // Pragmatic v1 (documented in the schema): only-untagged means the // bag is EXACTLY '{}' (column is NOT NULL DEFAULT '{}'). Partially // tagged lines (e.g. only dim 1 set) do not match. if (onlyUntagged) l = l.filter('dimensions', 'eq', '{}') return l } /** journal_entries page size (PostgREST's own cap). */ const ENTRY_PAGE_SIZE = 1000 /** Entry ids per fetchLinesByEntryIds call: its own chunk width, so each * call is exactly one `.in()` query and the early-stop check runs * between every query rather than after a large batch. */ const LINE_CHUNK_SIZE = 100 type EntryRow = { id: string entry_date: string voucher_number: number voucher_series: string } type BareLineRow = { id: string journal_entry_id: string account_number: string debit_amount: number credit_amount: number sort_order: number } const rows: MatchedRow[] = [] try { const seenEntryIds = new Set() let entryFrom = 0 paging: while (true) { const { data: entryPage, error: entryError } = await filterEntries( supabase .from('journal_entries') .select('id, entry_date, voucher_number, voucher_series, status'), ) // Stable total order on the PK for correct paging (same invariant // as lib/supabase/fetch-all.ts). .order('id', { ascending: true }) .range(entryFrom, entryFrom + ENTRY_PAGE_SIZE - 1) if (entryError) throw new Error(entryError.message) const pageEntries = ((entryPage ?? []) as EntryRow[]).filter( (e) => !seenEntryIds.has(e.id), ) for (const e of pageEntries) seenEntryIds.add(e.id) const entryById = new Map(pageEntries.map((e) => [e.id, e])) for (let i = 0; i < pageEntries.length; i += LINE_CHUNK_SIZE) { const chunkIds = pageEntries.slice(i, i + LINE_CHUNK_SIZE).map((e) => e.id) const chunkLines = await fetchLinesByEntryIds( supabase, chunkIds, 'id, account_number, debit_amount, credit_amount, sort_order', filterLines, ) for (const line of chunkLines) { const parent = entryById.get(line.journal_entry_id) if (!parent) continue rows.push({ ...line, journal_entries: parent } as MatchedRow) } // Short-circuit: one line past the cap already decides the // outcome, so stop fetching instead of walking the rest of the // match set. if (rows.length > RETAG_MAX_LINES) break paging } if (!entryPage || entryPage.length < ENTRY_PAGE_SIZE) break entryFrom += ENTRY_PAGE_SIZE } } catch (err) { log.warn('tag_journal_lines match query failed', { companyId, userId, error: err instanceof Error ? err.message : String(err), }) throw new Error('Database error while matching journal lines') } // Verifikat-major preview order, newest first, then line order inside // the voucher. Done in JS: the sort keys live on the parent entry and // PostgREST's `.order(col, { foreignTable })` sorts the EMBEDDED rows, // not the parent result set. rows.sort((a, b) => { const ad = a.journal_entries.entry_date const bd = b.journal_entries.entry_date if (ad !== bd) return ad < bd ? 1 : -1 const av = a.journal_entries.voucher_number const bv = b.journal_entries.voucher_number if (av !== bv) return bv - av if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 }) if (rows.length === 0) { throw new Error( 'Inga bokförda rader matchade filtret. Kontrollera konto/datum/text: förhandsgranska med gnubok_query_journal (samma filterfält).', ) } if (rows.length > RETAG_MAX_LINES) { throw new Error( `Filtret matchar fler än ${RETAG_MAX_LINES} rader: snäva av det (kortare datumintervall, färre konton) och kör i omgångar om högst ${RETAG_MAX_LINES}.`, ) } // Human description of the selection, carried on the op for the // approval preview (the executor acts on line_ids verbatim). const summaryParts: string[] = [] if (accounts && accounts.length > 0) summaryParts.push(`konto ${accounts.join(', ')}`) else if (accountFrom || accountTo) summaryParts.push(`konto ${accountFrom ?? '…'}-${accountTo ?? '…'}`) if (dateFrom || dateTo) summaryParts.push(`datum ${dateFrom ?? '…'}-${dateTo ?? '…'}`) if (text) summaryParts.push(`text "${text}"`) if (onlyUntagged) summaryParts.push('endast otaggade rader') const filterSummary = summaryParts.join(', ').slice(0, 500) const bagLabel = Object.entries(resolvedBag) .map(([dim, code]) => `${dim}=${code}`) .join(', ') // Same Zod schema the commit executor re-validates with: the staged // params can never drift from what commitRetagLineDimensions accepts. const params = RetagLineDimensionsParamsSchema.parse({ line_ids: rows.map((r) => r.id), dimensions: resolvedBag, reason, filter_summary: filterSummary, }) // No dateForPeriodCheck: the matched lines span dates; the retag RPC // enforces open-period + lock-date per line at commit time. return stagePendingOperation(supabase, companyId, userId, 'retag_line_dimensions', `Tagga om ${rows.length} verifikationsrader: ${bagLabel}`, params as unknown as Record, { matched_lines: rows.length, dimensions: resolvedBag, filter_summary: filterSummary, sample: rows.slice(0, 10).map((r) => ({ account: r.account_number, date: r.journal_entries.entry_date, debit: r.debit_amount, credit: r.credit_amount, })), ...(resolutions.length > 0 ? { dimension_resolutions: resolutions } : {}), will: 'replace the dimensions bag on every matched POSTED line via the audited retag RPC: internal reporting only, the verifikat itself is untouched', }, actor, { description: 'After approval, verify the retag with gnubok_query_journal (group_by_dimension) or gnubok_get_dimension_pnl.', tool: 'gnubok_query_journal', args: { group_by_dimension: Object.keys(resolvedBag)[0] }, }, { dryRun: Boolean(dry_run), idempotencyKey: typeof idempotency_key === 'string' ? idempotency_key : undefined, } ) }, }, { name: 'gnubok_get_dimension_pnl', title: 'P&L per Dimension (Resultat per projekt)', description: 'Resultat per projekt/kostnadsställe: P&L matrix over one SIE dimension: each value with activity becomes a column plus an untagged bucket, and the Totalt column reconciles exactly with the resultatrapport. sie_dim_no: 1 = kostnadsställe, 6 = projekt.', inputSchema: { type: 'object', additionalProperties: false, properties: { sie_dim_no: { type: 'string', description: "SIE dimension number: '1' = kostnadsställe, '6' = projekt, or a custom dim from gnubok_list_dimensions." }, period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, to_date: { type: 'string', description: 'Optional end date (YYYY-MM-DD); the matrix is always cumulative from period start (closing-balance semantics, reconciles with resultatrapport)' }, }, required: ['sie_dim_no'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { dimension: { type: 'object', properties: { sie_dim_no: { type: 'string' }, name: { type: 'string' }, }, }, columns: { type: 'array', description: 'One per value with activity; code null = the "(Utan dimension)" residual bucket.', items: { type: 'object', properties: { code: { type: ['string', 'null'] }, name: { type: ['string', 'null'] }, }, }, }, groups: { type: 'array', description: 'BAS class groups (3-8); each row\'s values[] aligns with columns[].', items: { type: 'object', properties: { class: { type: 'number' }, class_label: { type: 'string' }, rows: { type: 'array', items: { type: 'object', properties: { account_number: { type: 'string' }, account_name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } }, total: { type: 'number' }, }, }, }, subtotals: { type: 'array', items: { type: 'number' } }, subtotal_total: { type: 'number' }, }, }, }, net_per_column: { type: 'array', items: { type: 'number' } }, net_total: { type: 'number', description: 'Matches resultatrapport net result for the same window.' }, period: { type: 'object', properties: { start: { type: 'string' }, end: { type: 'string' } }, }, }, required: ['dimension', 'columns', 'groups', 'net_per_column', 'net_total'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const sieDimNo = String(args.sie_dim_no ?? '').trim() // Positive-integer guard: the value is interpolated into a PostgREST // jsonb path expression downstream, so free-form strings are rejected. if (!/^[1-9]\d{0,3}$/.test(sieDimNo)) { throw new Error("sie_dim_no must be a positive SIE dimension number, e.g. '1' (kostnadsställe) or '6' (projekt).") } let periodId = args.period_id as string | undefined // If no period specified, find the most recent one (same default as // gnubok_get_trial_balance). if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id, name') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) { throw new Error('No fiscal periods found. Categorize some transactions first to auto-create a period.') } periodId = periods.id } const toDate = args.to_date as string | undefined return await generateDimensionPnl(supabase, companyId, periodId!, sieDimNo, { toDate }) }, }, // ── Reports ────────────────────────────────────────────────── { name: 'gnubok_get_balance_sheet', title: 'Balance Sheet (Balansräkning)', description: 'Balance sheet (balansräkning) for a fiscal period: assets, equity, and liabilities sections with totals + balance check.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) throw new Error('No fiscal periods found. Create one first.') periodId = periods.id } const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', periodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found.') const result = await generateBalanceSheet(supabase, companyId, periodId!) return { period_name: period.name, ...result, period: { start: period.period_start, end: period.period_end }, } }, }, { name: 'gnubok_get_general_ledger', title: 'General Ledger (Huvudbok)', description: 'General ledger (huvudbok) for a fiscal period: per-account opening, entries, closing balances. Optional account range + dimensions filters. For ad-hoc cross-account/amount/free-text queries use gnubok_query_journal.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, account_from: { type: 'string', description: 'Starting account number filter' }, account_to: { type: 'string', description: 'Ending account number filter' }, dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA, }, }, outputSchema: { type: 'object', properties: { ...DIMENSION_FILTER_OUTPUT_PROPS }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) throw new Error('No fiscal periods found.') periodId = periods.id } const accountFrom = args.account_from as string | undefined const accountTo = args.account_to as string | undefined const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions) const report = await generateGeneralLedger( supabase, companyId, periodId!, accountFrom, accountTo, dimFilter.filter ? { dimensions: dimFilter.filter } : undefined, ) return { ...report, ...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}), ...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}), } }, }, { name: 'gnubok_query_journal', title: 'Query Journal Lines', description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher, source, status, dimensions bag, free-text. group_by/group_by_dimension aggregation; include_dimensions returns each line's bag. Lines + totals over the full match set (totals_scope).", inputSchema: { type: 'object', additionalProperties: false, properties: { account_from: { type: 'string', description: 'Lowest account number (inclusive). E.g. "4000" with account_to "4999" → all class-4 expenses.' }, account_to: { type: 'string', description: 'Highest account number (inclusive)' }, accounts: { type: 'array', items: { type: 'string' }, description: 'Specific account numbers (overrides account_from/account_to). Up to 50.' }, date_from: { type: 'string', description: 'Earliest entry date (YYYY-MM-DD, inclusive)' }, date_to: { type: 'string', description: 'Latest entry date (YYYY-MM-DD, inclusive)' }, amount_min: { type: 'number', description: 'Minimum line amount (absolute value of debit OR credit)' }, amount_max: { type: 'number', description: 'Maximum line amount (absolute value)' }, text: { type: 'string', maxLength: 200, description: 'Free-text search in entry description and line description (max 200 chars)' }, voucher_series: { type: 'string', description: 'Filter by voucher series (e.g. "A")' }, voucher_number_from: { type: 'number', description: 'Lowest voucher number (inclusive)' }, voucher_number_to: { type: 'number', description: 'Highest voucher number (inclusive)' }, source_type: { type: 'string', description: 'Filter by source: bank_transaction, invoice_created, supplier_invoice, currency_revaluation, year_end, opening_balance, etc.' }, status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' }, project: { type: 'string', description: 'Filter by project code (SIE dim 6)' }, cost_center: { type: 'string', description: 'Filter by cost center (SIE dim 1)' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Filter: SIE dim no → value (code OR name, resolved server-side), e.g. {"6":"P001"}. Containment match; covers custom dims unlike project/cost_center.', }, include_dimensions: { type: 'boolean', description: "Return each line's dimensions bag (default false).", }, group_by: { type: 'string', enum: ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'], description: 'Aggregate matching lines into groups by this field. Mutually exclusive with group_by_dimension.' }, group_by_dimension: { type: 'string', description: 'Aggregate by SIE dimension number (e.g. "6" = projekt) from each line\'s dimensions bag; untagged → "(utan dimension)". Mutually exclusive with group_by.' }, limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1-500 (default 100). Totals/groups cover the FULL match set even when truncated, except under free-text search (see totals_scope).' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { lines: { type: 'array', items: { type: 'object' } }, truncated: { type: 'boolean', description: 'True if more matching lines exist than were returned' }, total_lines: { type: 'number', description: 'Total lines matching ALL filters (incl. amount). When amount_min/amount_max is set this reflects the filtered set, not the wider DB-side match.' }, returned_lines: { type: 'number' }, amount_filter_applied_post_fetch: { type: 'boolean', description: 'True if amount_min/amount_max was applied client-side after the DB fetch.' }, db_matched_pre_amount_filter: { type: ['number', 'null'], description: 'Pre-amount-filter DB match count when amount_filter_applied_post_fetch is true; null otherwise.' }, totals: { type: 'object', properties: { debit: { type: 'number' }, credit: { type: 'number' }, net: { type: 'number', description: 'debit minus credit (positive = net debit)' }, }, }, totals_scope: { type: 'string', enum: ['full_match', 'returned_slice'], description: 'full_match: totals/groups aggregate ALL matching lines regardless of limit. returned_slice: free-text search aggregates only the returned window.', }, groups: { type: 'array', items: { type: 'object', properties: { key: { type: 'string' }, debit: { type: 'number' }, credit: { type: 'number' }, net: { type: 'number' }, line_count: { type: 'number' }, }, }, description: 'Present when group_by/group_by_dimension is set; sorted by |net| desc. Scope follows totals_scope.', }, applied_filters: { type: 'object' }, ...DIMENSION_FILTER_OUTPUT_PROPS, }, required: ['lines', 'total_lines', 'returned_lines', 'totals', 'totals_scope'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 100), 500) const status = (args.status as string) || 'posted' const accounts = args.accounts as string[] | undefined const accountFrom = args.account_from as string | undefined const accountTo = args.account_to as string | undefined if (accounts && accounts.length > 50) { throw new Error('accounts list capped at 50: use account_from/account_to for ranges') } const dateFrom = args.date_from as string | undefined const dateTo = args.date_to as string | undefined const voucherSeries = args.voucher_series as string | undefined const vnFrom = args.voucher_number_from as number | undefined const vnTo = args.voucher_number_to as number | undefined const sourceType = args.source_type as string | undefined const project = args.project as string | undefined const costCenter = args.cost_center as string | undefined const includeDimensions = args.include_dimensions === true // Resolve-don't-select: value NAMES resolve to registry codes; the // containment filter then hits the GIN index on the jsonb bag. const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions) const GROUP_BY_FIELDS = ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'] as const const groupBy = args.group_by as (typeof GROUP_BY_FIELDS)[number] | undefined const groupByDimension = args.group_by_dimension !== undefined && args.group_by_dimension !== null ? String(args.group_by_dimension).trim() : undefined if (groupBy && groupByDimension) { throw new Error('Use either group_by or group_by_dimension, not both') } if (groupBy && !GROUP_BY_FIELDS.includes(groupBy)) { throw new Error(`group_by must be one of: ${GROUP_BY_FIELDS.join(', ')}`) } // Positive-integer guard: the schema says string but hosts don't always // validate, and the value keys into the dimensions jsonb bag. if (groupByDimension && !/^[1-9]\d{0,3}$/.test(groupByDimension)) { throw new Error('group_by_dimension must be a positive SIE dimension number, e.g. "6" (projekt)') } const wantsGroups = Boolean(groupBy || groupByDimension) // The dimensions jsonb only rides along when something needs it (a // dimension group, the bag filter's echo, or include_dimensions): it is // the widest column on the line and the aggregate pass fetches ALL rows. const dimsSelect = groupByDimension || includeDimensions || dimFilter.filter ? ', dimensions' : '' // Free-text legs only. The embed survives here on purpose: each leg is // capped at `legLimit` rows, and that cap (which drives legCapHit and // the `truncated` signal) has no equivalent in the two-step fetch, // which would have to pull the whole ilike match set unbounded. Every // other pass uses fetchEntryLines: see ENTRY_COLUMNS/LINE_COLUMNS. const DISPLAY_SELECT = `id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center${dimsSelect}, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, notes, source_type, status, company_id)` // Column lists for the two-step entry-lines fetch (the non-text path). // Same fields as DISPLAY_SELECT, split across the two queries the // helper issues; company_id is implied by the entry-side filter. const ENTRY_COLUMNS = 'id, voucher_number, voucher_series, entry_date, description, notes, source_type, status' const LINE_COLUMNS = `id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center${dimsSelect}, sort_order` // Each query pass needs its own builder instance: PostgREST query // builders are not reusable across awaits. The factory closes over the // resolved filter values above and applies IDENTICAL filters for every // projection, so display, text legs, and the aggregate pass always see // the same match set. const buildFilteredQuery = (select: string) => { let q = supabase .from('journal_entry_lines') .select(select) .eq('journal_entries.company_id', companyId) if (status === 'all') { q = q.in('journal_entries.status', ['posted', 'reversed']) } else { q = q.eq('journal_entries.status', status) } if (accounts && accounts.length > 0) { q = q.in('account_number', accounts) } else { if (accountFrom) q = q.gte('account_number', accountFrom) if (accountTo) q = q.lte('account_number', accountTo) } if (dateFrom) q = q.gte('journal_entries.entry_date', dateFrom) if (dateTo) q = q.lte('journal_entries.entry_date', dateTo) if (voucherSeries) q = q.eq('journal_entries.voucher_series', voucherSeries) if (typeof vnFrom === 'number') q = q.gte('journal_entries.voucher_number', vnFrom) if (typeof vnTo === 'number') q = q.lte('journal_entries.voucher_number', vnTo) if (sourceType) q = q.eq('journal_entries.source_type', sourceType) if (project) q = q.eq('project', project) if (costCenter) q = q.eq('cost_center', costCenter) if (dimFilter.filter) q = q.contains('dimensions', dimFilter.filter) return q } // Same filter set as buildFilteredQuery, split for the two-step // entry-lines fetch: entry-level predicates become plain column filters // on journal_entries, line-level ones stay on journal_entry_lines. Keep // the three in sync: they must always describe one match set. const filterEntries = (q: EntryLinesQuery): EntryLinesQuery => { let e = q.eq('company_id', companyId) e = status === 'all' ? e.in('status', ['posted', 'reversed']) : e.eq('status', status) if (dateFrom) e = e.gte('entry_date', dateFrom) if (dateTo) e = e.lte('entry_date', dateTo) if (voucherSeries) e = e.eq('voucher_series', voucherSeries) if (typeof vnFrom === 'number') e = e.gte('voucher_number', vnFrom) if (typeof vnTo === 'number') e = e.lte('voucher_number', vnTo) if (sourceType) e = e.eq('source_type', sourceType) return e } const filterLines = (q: EntryLinesQuery): EntryLinesQuery => { let l = q if (accounts && accounts.length > 0) { l = l.in('account_number', accounts) } else { if (accountFrom) l = l.gte('account_number', accountFrom) if (accountTo) l = l.lte('account_number', accountTo) } if (project) l = l.eq('project', project) if (costCenter) l = l.eq('cost_center', costCenter) if (dimFilter.filter) l = l.contains('dimensions', dimFilter.filter) return l } type LineRow = { id: string account_number: string debit_amount: number credit_amount: number currency: string | null line_description: string | null project: string | null cost_center: string | null dimensions?: Record | null sort_order: number journal_entries: { id: string voucher_number: number voucher_series: string entry_date: string description: string notes: string | null source_type: string status: string } } // Display ordering: verifikat-major, newest first, then line order // inside the voucher, with the line id as a deterministic tiebreak. // Applied in JS because the sort keys live on the parent entry: // PostgREST's `.order(col, { foreignTable })` sorts the EMBEDDED rows, // not the parent result set, so this order was never produced server // side. const byDisplayOrder = (a: LineRow, b: LineRow) => { const ad = a.journal_entries.entry_date const bd = b.journal_entries.entry_date if (ad !== bd) return ad < bd ? 1 : -1 const av = a.journal_entries.voucher_number const bv = b.journal_entries.voucher_number if (av !== bv) return bv - av if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 } // Free-text search runs as two parallel .ilike() queries: one against // line_description (base table) and one against journal_entries.description // (embedded resource). PostgREST's flat .or() filter cannot span a base // column and an embedded-resource column ("failed to parse logic tree"), // so we issue two queries and merge by line id. Same pattern as // lib/invoices/duplicate-payment-candidates.ts. const text = (args.text as string | undefined)?.trim() let data: LineRow[] = [] let dbMatched = 0 // Full match set (non-text path only) so totals and groups are exact // regardless of `limit`. The free-text path stays slice-scoped (its // per-leg windows make a full pass unbounded) and says so via // totals_scope='returned_slice'. let fullRows: LineRow[] | null = null // True when at least one text-search leg filled its per-leg fetch // window: i.e. more matches probably exist on the DB side that didn't // make it into the merge. Drives the `truncated` signal honestly even // when the merged distinct set fits inside `limit`. let legCapHit = false if (text) { // Length guard: defence in depth against pathological inputs even // though .ilike() parameterises the value (compliance A.8.28). if (text.length > 200) { throw new Error('text filter must be 200 characters or shorter') } // LIKE wildcards `%` and `_` are escaped so a search for "2_441" // matches the literal string. Comma stripping is intentionally NOT // applied here: the previous implementation needed it because the // value was interpolated into PostgREST's OR DSL where `,` is the // separator. The .ilike() path passes the pattern as a parameterised // filter operand where `,` is a literal: stripping would mangle // searches for real commas in line descriptions. // // Backslash is escaped FIRST, and the order matters: `\` is LIKE's own // escape character, so an unescaped one in the search term swallows the // character after it (searching `a\b` matched rows containing `ab`). // Escaping it last would instead double the backslashes the % / _ rules // just added. const escaped = text .replace(/\\/g, '\\\\') .replace(/%/g, '\\%') .replace(/_/g, '\\_') const pattern = `%${escaped}%` // Fetch up to 2× limit per leg to reduce global-ordering loss when // one leg is much more selective than the other (e.g. 150 line // matches vs 5 entry matches with limit=100). Hard-capped at 500 // rows per leg so a caller-supplied `limit` near its own ceiling // can't fan out to 2× very large queries. The final post-merge // slice still caps at `limit`; the wider per-leg window just gives // the merge a better tail to choose from. const legLimit = Math.min(limit * 2, 500) const buildLeg = (column: 'line_description' | 'journal_entries.description') => buildFilteredQuery(DISPLAY_SELECT) .ilike(column, pattern) .order('entry_date', { foreignTable: 'journal_entries', ascending: false }) .order('voucher_number', { foreignTable: 'journal_entries', ascending: false }) .order('sort_order', { ascending: true }) .limit(legLimit) const [byLine, byEntry] = await Promise.all([ buildLeg('line_description'), buildLeg('journal_entries.description'), ]) if (byLine.error || byEntry.error) { log.warn('query_journal text-search failed', { companyId, userId, byLine: byLine.error?.message ?? null, byEntry: byEntry.error?.message ?? null, }) throw new Error('Database error while running text search') } const merged = new Map() for (const row of (byLine.data ?? []) as unknown as LineRow[]) merged.set(row.id, row) for (const row of (byEntry.data ?? []) as unknown as LineRow[]) { if (!merged.has(row.id)) merged.set(row.id, row) } data = Array.from(merged.values()).sort(byDisplayOrder).slice(0, limit) // Honest distinct-row count among what we fetched. If a leg hit its // window cap, more distinct matches may exist; `legCapHit` carries // that signal downstream so `truncated` isn't faked false. dbMatched = merged.size legCapHit = (byLine.data?.length ?? 0) >= legLimit || (byEntry.data?.length ?? 0) >= legLimit } else { // Non-text path: ONE two-step fetch (lib/bookkeeping/entry-lines.ts) // feeds both the display slice and the full-match aggregate pass. // The old code ran two `journal_entries!inner` embed queries here (a // display one and a lean aggregate one), each of which PostgREST // compiled into a correlated LATERAL join that walked every tenant's // journal_entry_lines. The display projection is a superset of the // aggregate one, so one pass over the same match set replaces both. try { fullRows = await fetchEntryLines({ supabase, entryColumns: ENTRY_COLUMNS, lineColumns: LINE_COLUMNS, filterEntries, filterLines, }) } catch (err) { log.warn('query_journal failed', { companyId, userId, error: err instanceof Error ? err.message : String(err), }) throw new Error('Database error while running journal query') } data = [...fullRows].sort(byDisplayOrder).slice(0, limit) dbMatched = data.length } // Apply amount filter post-fetch: PostgREST can't OR an abs(debit) >= n // with abs(credit) >= n cleanly. Lines are debit XOR credit, so checking // max(debit, credit) works. The SAME predicate runs over the display // slice and the full aggregate set so both describe one match set. const amountMin = args.amount_min as number | undefined const amountMax = args.amount_max as number | undefined const amountFilterApplied = typeof amountMin === 'number' || typeof amountMax === 'number' const passesAmountFilter = (r: { debit_amount: number; credit_amount: number }) => { const lineAmount = Math.max(Number(r.debit_amount) || 0, Number(r.credit_amount) || 0) if (typeof amountMin === 'number' && lineAmount < amountMin) return false if (typeof amountMax === 'number' && lineAmount > amountMax) return false return true } const filtered = data.filter(passesAmountFilter) const fullFiltered = fullRows ? fullRows.filter(passesAmountFilter) : null // Totals aggregate over the full match set when available (non-text), // else over the returned slice (free-text): totals_scope tells the // agent which one it got. const totalsSource: Array<{ debit_amount: number; credit_amount: number }> = fullFiltered ?? filtered let totalDebit = 0 let totalCredit = 0 for (const r of totalsSource) { totalDebit += Number(r.debit_amount) || 0 totalCredit += Number(r.credit_amount) || 0 } const lines = filtered.map((r) => { return { line_id: r.id, journal_entry_id: r.journal_entries.id, voucher_series: r.journal_entries.voucher_series, voucher_number: r.journal_entries.voucher_number, entry_date: r.journal_entries.entry_date, entry_description: r.journal_entries.description, entry_notes: r.journal_entries.notes ?? null, source_type: r.journal_entries.source_type, status: r.journal_entries.status, account_number: r.account_number, debit: Number(r.debit_amount) || 0, credit: Number(r.credit_amount) || 0, line_description: r.line_description, project: r.project, cost_center: r.cost_center, ...(includeDimensions ? { dimensions: r.dimensions ?? {} } : {}), currency: r.currency, } }) // Optional group_by aggregation: over the same set totals used, so // group sums always reconcile with `totals`. let groups: | Array<{ key: string; debit: number; credit: number; net: number; line_count: number }> | undefined if (wantsGroups) { const groupSource: LineRow[] = fullFiltered ?? filtered const keyOf = (r: LineRow): string => { if (groupByDimension) return r.dimensions?.[groupByDimension] ?? '(utan dimension)' switch (groupBy) { case 'voucher_series': return r.journal_entries.voucher_series case 'source_type': return r.journal_entries.source_type case 'cost_center': return r.cost_center ?? '(utan dimension)' case 'project': return r.project ?? '(utan dimension)' default: return r.account_number } } const bucketMap = new Map() for (const r of groupSource) { const key = keyOf(r) const bucket = bucketMap.get(key) ?? { debit: 0, credit: 0, count: 0 } bucket.debit += Number(r.debit_amount) || 0 bucket.credit += Number(r.credit_amount) || 0 bucket.count += 1 bucketMap.set(key, bucket) } groups = [...bucketMap.entries()] .map(([key, bucket]) => ({ key, debit: roundOre(bucket.debit), credit: roundOre(bucket.credit), net: roundOre(bucket.debit - bucket.credit), line_count: bucket.count, })) .sort((a, b) => Math.abs(b.net) - Math.abs(a.net)) } // Non-text path: the aggregate pass IS the full match set, so // total_lines / truncated / pre-amount count all anchor to it. Text // path: no full pass exists: total_lines stays slice-anchored exactly // as before (amount filter → post-filter slice; otherwise the merged // distinct count), and legCapHit keeps `truncated` honest. const total_lines = fullFiltered ? fullFiltered.length : amountFilterApplied ? lines.length : dbMatched const truncated = fullFiltered ? fullFiltered.length > lines.length : amountFilterApplied ? data.length >= limit && lines.length === limit : dbMatched > lines.length || legCapHit return { lines, truncated, total_lines, returned_lines: lines.length, amount_filter_applied_post_fetch: amountFilterApplied, db_matched_pre_amount_filter: amountFilterApplied ? (fullRows ? fullRows.length : dbMatched) : null, totals: { debit: Math.round(totalDebit * 100) / 100, credit: Math.round(totalCredit * 100) / 100, net: Math.round((totalDebit - totalCredit) * 100) / 100, }, totals_scope: fullFiltered ? 'full_match' : 'returned_slice', ...(groups ? { groups } : {}), applied_filters: { account_from: accountFrom ?? null, account_to: accountTo ?? null, accounts: accounts ?? null, date_from: dateFrom ?? null, date_to: dateTo ?? null, amount_min: amountMin ?? null, amount_max: amountMax ?? null, text: text ?? null, voucher_series: voucherSeries ?? null, voucher_number_from: vnFrom ?? null, voucher_number_to: vnTo ?? null, source_type: sourceType ?? null, status, project: project ?? null, cost_center: costCenter ?? null, dimensions: dimFilter.filter ?? null, group_by: groupBy ?? null, group_by_dimension: groupByDimension ?? null, }, ...(dimFilter.filter ? { dimension_filter: dimFilter.filter } : {}), ...(dimFilter.resolutions.length > 0 ? { dimension_resolutions: dimFilter.resolutions } : {}), } }, }, { name: 'gnubok_get_ar_ledger', title: 'AR Ledger (Kundreskontra)', description: 'Accounts receivable ledger (kundreskontra): outstanding customer invoices with aging.', inputSchema: { type: 'object', additionalProperties: false, properties: { as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default: today)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const asOfDate = args.as_of_date as string | undefined return await generateARLedger(supabase, companyId, asOfDate) }, }, { name: 'gnubok_get_supplier_ledger', title: 'AP Ledger (Leverantörsreskontra)', description: 'Accounts payable ledger (leverantörsreskontra): outstanding supplier invoices with aging.', inputSchema: { type: 'object', additionalProperties: false, properties: { as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default: today)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const asOfDate = args.as_of_date as string | undefined return await generateSupplierLedger(supabase, companyId, asOfDate) }, }, // ── Transaction Matching ───────────────────────────────────── { name: 'gnubok_match_transaction_to_invoice', title: 'Match Transaction to Invoice', description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer before staging. Supports partial payments and auto-storno of prior categorization.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string', description: 'UUID of the bank transaction' }, invoice_id: { type: 'string', description: 'UUID of the invoice to match' }, }, required: ['transaction_id', 'invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string const invoiceId = args.invoice_id as string if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required') // Validate both exist and are matchable const { data: transaction, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, date, invoice_id') .eq('id', transactionId) .eq('company_id', companyId) .single() if (txError || !transaction) throw new Error('Transaction not found') if (transaction.amount <= 0) throw new Error('Only income transactions (amount > 0) can be matched to invoices') if (transaction.invoice_id) throw new Error('Transaction is already linked to an invoice') const { data: invoice, error: invError } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invError || !invoice) throw new Error('Invoice not found') if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') { throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)') } const txDesc = transaction.merchant_name || transaction.description || transactionId return stagePendingOperation(supabase, companyId, userId, 'match_transaction_invoice', `Matcha: ${txDesc} → ${invoice.invoice_number}`, { transaction_id: transactionId, invoice_id: invoiceId }, { transaction_description: txDesc, transaction_amount: transaction.amount, transaction_currency: transaction.currency, // Surface both dates so the reviewer can spot a material mismatch // between the payment and the invoice it's being matched against // before approving. transaction_date: transaction.date, invoice_number: invoice.invoice_number, invoice_total: invoice.total, invoice_currency: invoice.currency, invoice_date: invoice.invoice_date, customer_name: (invoice.customer as Record)?.name as string, }, actor, { description: 'After approval the transaction is linked and the invoice is marked paid. Use gnubok_get_ar_ledger to verify the customer balance.', tool: 'gnubok_get_ar_ledger', } ) }, }, { name: 'gnubok_match_batch_allocate', title: 'Batch-Allocate Payment', description: 'Allocate 1 bank tx across N customer OR N supplier invoices (samlingsbetalning, BFL 5 kap 6§). Use when one receipt covers many invoices or one transfer pays many bills. Stages.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string' }, allocations: { type: 'array', minItems: 1, maxItems: 100, items: { type: 'object', additionalProperties: false, properties: { kind: { type: 'string', enum: ['customer_invoice', 'supplier_invoice'] }, invoice_id: { type: 'string' }, supplier_invoice_id: { type: 'string' }, amount: { type: 'number', exclusiveMinimum: 0, description: 'Amount in TX currency. Cross-currency = bank-credited SEK.' }, }, required: ['kind', 'amount'], }, }, }, required: ['transaction_id', 'allocations'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string const allocations = args.allocations as Array<{ kind: 'customer_invoice' | 'supplier_invoice' invoice_id?: string supplier_invoice_id?: string amount: number }> if (!transactionId) throw new Error('transaction_id is required') if (!Array.isArray(allocations) || allocations.length === 0) { throw new Error('allocations is required (non-empty array)') } const { data: transaction, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, date, journal_entry_id') .eq('id', transactionId) .eq('company_id', companyId) .single() if (txError || !transaction) throw new Error('Transaction not found') if (transaction.journal_entry_id) throw new Error('Transaction is already booked') if (transaction.amount === 0) throw new Error('Transaction has zero amount') // Direction guard mirrors the RPC: customer_invoice → income, supplier_invoice → expense. const hasCustomer = allocations.some((a) => a.kind === 'customer_invoice') const hasSupplier = allocations.some((a) => a.kind === 'supplier_invoice') if (hasCustomer && hasSupplier) { throw new Error('Cannot mix customer_invoice and supplier_invoice allocations in one batch') } if (hasCustomer && transaction.amount <= 0) { throw new Error('Customer allocations require an income transaction (amount > 0)') } if (hasSupplier && transaction.amount >= 0) { throw new Error('Supplier allocations require an expense transaction (amount < 0)') } // Per-allocation guard (Greptile P1): each row must carry the // correct ID for its kind. The inputSchema marks both invoice_id // and supplier_invoice_id as optional because they're mutually // exclusive, but the JSON-Schema vocabulary can't express "X // required iff Y=A". Check explicitly here. Round-8: also reject // unexpected extra IDs (V4.5): a customer_invoice row supplying // supplier_invoice_id silently leaks the extra ID into preview_data. for (const [i, a] of allocations.entries()) { if (a.kind === 'customer_invoice') { if (!a.invoice_id) { throw new Error(`allocations[${i}]: invoice_id is required when kind=customer_invoice`) } if (a.supplier_invoice_id) { throw new Error(`allocations[${i}]: supplier_invoice_id must not be set when kind=customer_invoice`) } } else if (a.kind === 'supplier_invoice') { if (!a.supplier_invoice_id) { throw new Error(`allocations[${i}]: supplier_invoice_id is required when kind=supplier_invoice`) } if (a.invoice_id) { throw new Error(`allocations[${i}]: invoice_id must not be set when kind=supplier_invoice`) } } } // Tenant-isolation pre-check (OWASP V8.2.1): verify every // referenced invoice belongs to this company BEFORE staging. // The RPC also re-checks this, but failing fast at the MCP // layer gives the agent a clear error instead of an opaque // BATCH_INVOICE_NOT_FOUND code at commit time. const invoiceIds = allocations .filter((a) => a.kind === 'customer_invoice') .map((a) => a.invoice_id!) const supplierInvoiceIds = allocations .filter((a) => a.kind === 'supplier_invoice') .map((a) => a.supplier_invoice_id!) // Belt-and-suspenders (CC6.1): assert both count equality AND the // missing-set is empty. The Supabase REST client de-dupes by PK so // count >= unique input length is enough on its own, but the // explicit guard prevents an undefined-row edge case in the JSON // response from silently passing. if (invoiceIds.length > 0) { const uniqueIds = Array.from(new Set(invoiceIds)) const { data: found } = await supabase .from('invoices') .select('id') .in('id', uniqueIds) .eq('company_id', companyId) const foundRows = found ?? [] const foundSet = new Set(foundRows.map((r) => r.id)) const missing = uniqueIds.filter((id) => !foundSet.has(id)) if (missing.length > 0 || foundRows.length !== uniqueIds.length) { throw new Error(`Invoices not found for this company: ${missing.join(', ') || '(count mismatch)'}`) } } if (supplierInvoiceIds.length > 0) { const uniqueIds = Array.from(new Set(supplierInvoiceIds)) const { data: found } = await supabase .from('supplier_invoices') .select('id') .in('id', uniqueIds) .eq('company_id', companyId) const foundRows = found ?? [] const foundSet = new Set(foundRows.map((r) => r.id)) const missing = uniqueIds.filter((id) => !foundSet.has(id)) if (missing.length > 0 || foundRows.length !== uniqueIds.length) { throw new Error(`Supplier invoices not found for this company: ${missing.join(', ') || '(count mismatch)'}`) } } const totalAllocated = allocations.reduce((sum, a) => sum + a.amount, 0) const txAbs = Math.abs(transaction.amount) // 0.005 SEK tolerance is for floating-point equalisation only, // NOT a rounding allowance. The RPC `match_batch_allocate` // re-enforces the same guard (BATCH_AMOUNT_EXCEEDS_TX / // BATCH_AMOUNT_BELOW_TX) authoritatively (per PR #607 round 3), // and the verifikat lines balance exactly to the öre. if (Math.abs(totalAllocated - txAbs) > 0.005) { throw new Error( `Allocations sum (${totalAllocated.toFixed(2)}) must equal transaction amount (${txAbs.toFixed(2)})` ) } const txDesc = transaction.merchant_name || transaction.description || transactionId // Swedish plurals: kundfaktura → kundfakturor (not kundfakturaor). // Same for leverantörsfaktura → leverantörsfakturor. const noun = hasCustomer ? 'kundfaktura' : 'leverantörsfaktura' const summary = `${allocations.length} ${allocations.length === 1 ? noun : `${noun.slice(0, -1)}or`}` return stagePendingOperation(supabase, companyId, userId, 'match_batch_allocate', `Fördela: ${txDesc} → ${summary}`, { transaction_id: transactionId, allocations }, // GDPR Art.25: transaction_description is included in preview_data // so the user can recognise the tx at approval time (merchant_name // or fallback to bank description). Same trade-off documented on // gnubok_link_transaction_to_journal_entry: it's the minimum // signal needed for an informed approval. Counterparty-identifying // invoice IDs stay in params (audit trail); they are NOT echoed // back into preview_data beyond aggregate counts. { transaction_description: txDesc, transaction_amount: transaction.amount, transaction_currency: transaction.currency, transaction_date: transaction.date, allocations_count: allocations.length, allocations_kind: hasCustomer ? 'customer_invoice' : 'supplier_invoice', total_allocated: totalAllocated, }, actor, { description: 'After approval the combined verifikat is created and each invoice is advanced. Verify with gnubok_get_ar_ledger (customer) or gnubok_get_supplier_ledger.', tool: hasCustomer ? 'gnubok_get_ar_ledger' : 'gnubok_get_supplier_ledger', }, { dateForPeriodCheck: transaction.date } ) }, }, { name: 'gnubok_link_transaction_to_journal_entry', title: 'Link Transaction to Verifikat', description: 'Link 1 bank tx to an already-posted verifikat (no new bokföring). Use when the user booked the affärshändelse manually. Pass invoice_id to also settle a kundfaktura. Stages.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string' }, journal_entry_id: { type: 'string' }, invoice_id: { type: 'string', description: 'Optional kundfaktura to settle alongside the link.' }, }, required: ['transaction_id', 'journal_entry_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string const journalEntryId = args.journal_entry_id as string const invoiceId = (args.invoice_id as string | undefined) ?? undefined if (!transactionId || !journalEntryId) { throw new Error('transaction_id and journal_entry_id are required') } // Tenant-isolation + state pre-checks (OWASP V8.2.1). The commit // handler re-validates authoritatively; failing fast at stage time // gives the agent a clean error before the user is asked to approve. const { data: tx, error: txError } = await supabase .from('transactions') .select('id, date, amount, currency, journal_entry_id, description, merchant_name') .eq('id', transactionId) .eq('company_id', companyId) .maybeSingle() if (txError || !tx) throw new Error('Transaction not found') // Only a live posted link blocks re-linking; a stale pointer at a reversed // entry (storno/correction) reads as "utan koppling" and must stay // re-linkable (issue #988). The commit handler re-validates the same way. if (tx.journal_entry_id && (await hasLiveJournalEntryLink(supabase, companyId, tx.journal_entry_id))) { throw new Error('Transaction is already linked to a journal entry') } const { data: je, error: jeError } = await supabase .from('journal_entries') .select('id, status, voucher_series, voucher_number, entry_date') .eq('id', journalEntryId) .eq('company_id', companyId) .maybeSingle() if (jeError || !je) throw new Error('Journal entry not found') if (je.status !== 'posted') { throw new Error(`Journal entry must be posted (status=${je.status})`) } let invoicePreview: { invoice_number: string | null; remaining: number | null; will_be_fully_paid: boolean } | null = null if (invoiceId) { // GDPR Art.5(1)(c): only the columns the preview displays. We need // remaining_amount for the will-be-fully-paid math, invoice_number // for the staged-op title, and currency so we can fast-fail the // mismatch before the user is asked to approve (the commit handler // re-checks authoritatively via LINK_TX_INVOICE_CURRENCY_MISMATCH). const { data: invoice, error: invError } = await supabase .from('invoices') .select('id, invoice_number, status, currency, remaining_amount') .eq('id', invoiceId) .eq('company_id', companyId) .maybeSingle() if (invError || !invoice) throw new Error('Invoice not found') if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) { throw new Error(`Invoice is not in an open state (status=${invoice.status})`) } // Currency-mismatch pre-stage check (swedish-compliance PR #614 // round 8). The link-to-existing-voucher contract requires tx and // invoice currency to match: cross-currency settlement must go // through the match-invoice flow that posts 3960/7960 FX-diff // lines via buildInvoicePaymentClearingLines. Failing fast here // saves the user an approval round-trip. if (tx.currency !== invoice.currency) { throw new Error( `Transaction currency (${tx.currency}) does not match invoice currency (${invoice.currency}). Cross-currency settlement must go through the match-invoice flow.` ) } // Explicit NaN guard (A.8.28): silently treating a malformed numeric // column as 0 would let a bogus preview pass to the user. The DB // column is NUMERIC NOT NULL on remaining_amount once status leaves // 'draft', so a NaN here means something upstream is broken. const remaining = Number(invoice.remaining_amount) const txAmount = Number(tx.amount) if (!Number.isFinite(remaining) || !Number.isFinite(txAmount)) { throw new Error('Invoice remaining_amount or tx amount is not a finite number') } const newRemaining = Math.max(0, Math.round((remaining - txAmount) * 100) / 100) invoicePreview = { invoice_number: (invoice.invoice_number as string | null) ?? null, remaining: newRemaining, will_be_fully_paid: newRemaining <= 0, } } // Period-lock check uses the LATER of tx.date and je.entry_date so a // tx in an open period attached to a verifikat in a locked period // surfaces the period_status envelope correctly. Mirrors the same // logic in gnubok_bulk_book_transactions. const txDate = tx.date as string const jeDate = je.entry_date as string const periodCheckDate = jeDate > txDate ? jeDate : txDate // Centralised verifikat-label format (formatVoucherLabel): keeps the // MCP staging preview and the committed audit-trail label byte-identical, // so BFL 5 kap 7§ traceability holds even if the format ever changes. const voucherLabel = formatVoucherLabel( je.voucher_series as string | null, je.voucher_number as number | null, ) const txDesc = (tx.merchant_name as string | null) || (tx.description as string | null) || transactionId.slice(0, 8) return stagePendingOperation( supabase, companyId, userId, 'link_transaction_journal_entry', invoiceId ? `Länka ${txDesc} → verifikat ${voucherLabel} + faktura ${invoicePreview?.invoice_number ?? invoiceId.slice(0, 8)}` : `Länka ${txDesc} → verifikat ${voucherLabel}`, { transaction_id: transactionId, journal_entry_id: journalEntryId, invoice_id: invoiceId ?? null }, // GDPR Art.25: voucher_description is intentionally OMITTED from // preview_data: it can carry free-text merchant/counterparty PII // and the voucher_label alone uniquely identifies the verifikat for // the user's approval decision. Same reasoning as the per-tx // description handling elsewhere in this file. { transaction_description: txDesc, transaction_amount: tx.amount, transaction_currency: tx.currency, transaction_date: txDate, voucher_label: voucherLabel, voucher_date: jeDate, invoice_id: invoiceId ?? null, invoice_number: invoicePreview?.invoice_number ?? null, invoice_remaining_after: invoicePreview?.remaining ?? null, will_be_fully_paid: invoicePreview?.will_be_fully_paid ?? null, }, actor, { description: invoiceId ? 'After approval the tx attaches to the existing verifikat and the invoice flips to paid/partially_paid. No new bokföring. Verify with gnubok_get_ar_ledger.' : 'After approval the tx attaches to the existing verifikat. No new bokföring. Verify with gnubok_query_journal.', tool: invoiceId ? 'gnubok_get_ar_ledger' : 'gnubok_query_journal', }, { dateForPeriodCheck: periodCheckDate } ) }, }, { name: 'gnubok_bulk_book_transactions', title: 'Bulk-Book Transactions', description: 'Bulk-book N bank txs on the same date into 1 samlingsverifikat (BFL 5 kap 6§). Either link N txs to an existing posted verifikat, or create a new verifikat from caller lines (accept dims bags). All txs share date + direction. Stages.', inputSchema: { type: 'object', additionalProperties: false, properties: { tx_ids: { type: 'array', minItems: 1, maxItems: 200, items: { type: 'string' } }, existing_journal_entry_id: { type: 'string' }, new_entry: { type: 'object', additionalProperties: false, properties: { description: { type: 'string', minLength: 1, maxLength: 500 }, lines: { type: 'array', minItems: 2, maxItems: 200, items: { type: 'object', additionalProperties: false, properties: { account_number: { type: 'string', pattern: '^\\d{4}$' }, debit_amount: { type: 'number', minimum: 0 }, credit_amount: { type: 'number', minimum: 0 }, currency: { type: 'string', minLength: 3, maxLength: 3 }, line_description: { type: 'string', maxLength: 200 }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.', }, }, required: ['account_number', 'debit_amount', 'credit_amount', 'currency'], }, }, }, required: ['description', 'lines'], }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag keyed by SIE dim no, value = code OR name. Applied to every new_entry line not setting the key. Only valid with new_entry: a posted verifikat is immutable.', }, }, required: ['tx_ids'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const txIds = args.tx_ids as string[] const existingJeId = (args.existing_journal_entry_id as string | undefined) ?? null const newEntry = (args.new_entry as { description: string; lines: unknown[] } | undefined) ?? null if (!Array.isArray(txIds) || txIds.length === 0) throw new Error('tx_ids is required (non-empty)') if ((existingJeId == null) === (newEntry == null)) { throw new Error('Provide exactly one of existing_journal_entry_id or new_entry') } // Balance pre-check on the create-new path (compliance-swarm V2.3 // / swedish-compliance). The RPC also rejects with // BULK_BOOK_UNBALANCED, but failing fast here lets the agent get // a clear error before staging is even attempted. // The 0.005 tolerance is for floating-point equalisation only, // NOT a rounding allowance per BFL 5 kap 4-5§. The RPC enforces // exact balance to the öre on insert. if (newEntry) { const lines = (newEntry as { lines?: Array<{ debit_amount?: number; credit_amount?: number }> }).lines if (Array.isArray(lines) && lines.length > 0) { // Reject NaN / non-finite values explicitly (A.8.28). // `Number(x) || 0` silently treats NaN as 0; that would let // a malformed amount pass the balance check by accident. // Round-8 addition: reject debit=0 && credit=0 "ghost" lines // (BFL 5 kap 6§: every line must represent a real // bokföringspost with a non-zero amount). for (const [i, l] of lines.entries()) { const d = Number(l.debit_amount) const c = Number(l.credit_amount) if (!Number.isFinite(d) || !Number.isFinite(c)) { throw new Error(`new_entry.lines[${i}]: debit_amount and credit_amount must be finite numbers`) } if (d === 0 && c === 0) { throw new Error(`new_entry.lines[${i}]: debit_amount and credit_amount cannot both be zero (BFL 5 kap 6§)`) } } const totalDebit = lines.reduce((s, l) => s + Number(l.debit_amount), 0) const totalCredit = lines.reduce((s, l) => s + Number(l.credit_amount), 0) if (Math.abs(totalDebit - totalCredit) > 0.005) { throw new Error( `new_entry.lines must balance: debits=${totalDebit.toFixed(2)} credits=${totalCredit.toFixed(2)}` ) } } } // Resolve-don't-select: merge the batch-level default_dimensions under // each caller line's own bag, then resolve codes AND natural-language // names against the registry in ONE pass (zero queries when nothing is // tagged; free-text passthrough while dimensions_enabled is off). The // resolved bags are written back onto the staged new_entry lines: the // executor's RPC reads per-line dims only, so the merged default is // never staged top-level. const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') let dimensionResolutions: DimensionResolution[] = [] let stagedNewEntry = newEntry if (newEntry) { const rawLines = newEntry.lines as Array> const { bags, resolutions } = await resolveDimensionBags( supabase, companyId, rawLines.map((l, i) => mergeLineDimensions( { dimensions: parseDimensionsArg(l.dimensions, `new_entry.lines[${i}].dimensions`) }, defaultDimensions, ), ), ) dimensionResolutions = resolutions stagedNewEntry = { ...newEntry, lines: rawLines.map((l, i) => { const { dimensions: _rawDimensions, ...rest } = l const bag = bags[i] return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest }), } } else if (defaultDimensions) { throw new Error( 'default_dimensions only applies when creating a new verifikat (new_entry): a posted verifikat is immutable, so link-existing mode cannot be tagged.' ) } const { data: txs, error: txError } = await supabase .from('transactions') .select('id, amount, currency, date, journal_entry_id') .in('id', txIds) .eq('company_id', companyId) if (txError || !txs || txs.length !== txIds.length) { throw new Error('One or more transactions not found') } const booked = txs.find((t) => t.journal_entry_id != null) if (booked) throw new Error(`Transaction ${booked.id} is already booked`) const dates = new Set(txs.map((t) => t.date)) if (dates.size > 1) throw new Error('All transactions must share the same date') // Reject zero-amount txs (round-8 / A.8.28). The direction computation // below treats amount === 0 as 'expense' (amount > 0 is false), which // would then mis-classify a real income tx in the same batch. Mirrors // the explicit zero-amount guard in gnubok_match_batch_allocate. const zeroAmountTx = txs.find((t) => t.amount === 0) if (zeroAmountTx) throw new Error(`Transaction ${zeroAmountTx.id} has zero amount`) const direction = txs[0]!.amount > 0 ? 'income' : 'expense' if (txs.some((t) => (direction === 'income' ? t.amount < 0 : t.amount > 0))) { throw new Error('All transactions must share the same direction (all income or all expense)') } // Currency homogeneity (swedish-compliance): a samlingsverifikat // combining e.g. SEK + EUR txs without explicit FX lines violates // BFL 5 kap 2§ (alla belopp skall uttryckas i svenska kronor) // read together with the valutakurs rules in BFL 5 kap 6§. // Cross-currency batches should go through gnubok_match_batch_allocate // (which handles the FX diff on 7960/3960). Reject mixed currencies here. const currencies = new Set(txs.map((t) => t.currency)) if (currencies.size > 1) { // Route the agent to the cross-currency-capable tool rather // than letting it retry with hand-built FX lines. throw new Error( 'All transactions must share the same currency. For cross-currency allocations, use gnubok_match_batch_allocate (which handles the FX diff on 7960/3960).' ) } const txSum = txs.reduce((s, t) => s + t.amount, 0) const txDate = txs[0]!.date as string // For link-existing branch, also fetch the target JE and use the // LATER of tx_date and JE.entry_date for period-lock check // (swedish-compliance): otherwise a tx in an open period could be // attached to a verifikat in a closed period and the guard // would miss it. Same query also enforces tenant isolation on // the JE (OWASP V8.2.1) before the RPC sees the ID. let periodCheckDate = txDate if (existingJeId) { const { data: je, error: jeError } = await supabase .from('journal_entries') .select('id, entry_date, status') .eq('id', existingJeId) .eq('company_id', companyId) .maybeSingle() if (jeError || !je) { throw new Error('Existing journal entry not found for this company') } if (je.status !== 'posted') { throw new Error(`Existing journal entry must be posted (status=${je.status})`) } // Pass the later date so the period-lock guard fires on whichever // side is in a locked/closed period. periodCheckDate = (je.entry_date as string) > txDate ? (je.entry_date as string) : txDate } return stagePendingOperation(supabase, companyId, userId, 'bulk_book_transactions', existingJeId ? `Länka ${txIds.length} transaktioner till verifikat (${txDate})` : `Samlingsverifikation: ${txIds.length} transaktioner ${txDate}`, { tx_ids: txIds, existing_journal_entry_id: existingJeId, new_entry: stagedNewEntry, }, // GDPR Art.25: preview_data carries only aggregate counts + the // shared date/direction: no per-tx descriptions, no per-line // descriptions, no counterparty IDs. The user-facing approval // dialog reconstructs detail from the tx_ids list at render time // rather than persisting denormalized PII here. Same privacy-by- // design rationale as gnubok_link_transaction_to_journal_entry. { tx_count: txIds.length, tx_date: txDate, tx_sum: txSum, direction, mode: existingJeId ? 'link_existing' : 'create_new', // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), }, actor, { description: 'After approval the verifikat carries the combined business event. Verify with gnubok_query_journal or gnubok_get_reconciliation_status.', tool: 'gnubok_query_journal', }, { dateForPeriodCheck: periodCheckDate } ) }, }, { name: 'gnubok_bulk_book_inbox_items', title: 'Bulk-Book Underlag', description: 'Bulk-book N selected Underlag (Dokumentinkorgen) against their matched bank transactions with one shared category + VAT treatment. Set reverse_charge for foreign SaaS. Unmatched/booked items are skipped. Stages one approval.', inputSchema: { type: 'object', additionalProperties: false, properties: { item_ids: { type: 'array', minItems: 1, maxItems: 200, items: { type: 'string' }, description: "Inbox item UUIDs to book: the user's selection in the Underlag view.", }, category: { type: 'string', description: 'Shared transaction category applied to every item', enum: [...VALID_CATEGORIES] }, vat_treatment: { type: 'string', description: 'Shared VAT treatment. Set reverse_charge for foreign services (omvänd skattskyldighet) where the seller did NOT charge VAT: typical for USD/EUR SaaS subscriptions like Cursor/Anysphere. Defaults to standard_25.', enum: [...VALID_VAT_TREATMENTS] }, vat_amount: { type: 'number', exclusiveMinimum: 0, description: "The underlag's exact moms override; only valid with a rate-based vat_treatment. Rarely needed in bulk: all items share one value." }, notes: { type: 'string', description: 'Audit-trail note appended to every verifikation. Keep under 200 chars.' }, allow_duplicate: { type: 'boolean', description: 'Override the per-item duplicate-booking guard (default false). Set true only after the user confirms these bank lines are genuinely separate events.' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Shared dims bag {sie_dim_no: kod eller namn} applied to the business lines of every verifikat. Unknown values rejected: never auto-created.', }, }, required: ['item_ids', 'category'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const itemIds = args.item_ids as string[] if (!Array.isArray(itemIds) || itemIds.length === 0) throw new Error('item_ids is required (non-empty)') const vatAmount = typeof args.vat_amount === 'number' && Number.isFinite(args.vat_amount) ? args.vat_amount : undefined const notes = typeof args.notes === 'string' && args.notes.trim().length > 0 ? args.notes.trim() : undefined // Resolve-don't-select: codes AND natural-language names resolve against // the registry; the staged params carry only resolved codes. const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, [parseDimensionsArg(args.dimensions, 'dimensions')], ) const resolvedDimensions = resolvedDimBags[0] // Pre-flight: classify the selection so the preview (and the agent) sees // the real shape before staging. Tenant isolation via company_id. const { data: items, error } = await supabase .from('invoice_inbox_items') .select('id, matched_transaction_id, created_journal_entry_id, created_supplier_invoice_id') .in('id', itemIds) .eq('company_id', companyId) if (error) throw new Error(`Kunde inte läsa underlagen: ${error.message}`) const found = new Set((items ?? []).map((it) => it.id as string)) const resolved = (items ?? []).filter((it) => it.created_journal_entry_id || it.created_supplier_invoice_id) const bookable = (items ?? []).filter( (it) => it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id, ) const notMatched = (items ?? []).filter( (it) => !it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id, ).length const alreadyBooked = resolved.length const notFound = itemIds.filter((id) => !found.has(id)).length if (bookable.length === 0) { throw new Error( `Inga av de ${itemIds.length} valda underlagen kan bokföras: ${notMatched} saknar matchad banktransaktion, ` + `${alreadyBooked} är redan bokförda, ${notFound} hittades inte. Matcha underlagen mot en banktransaktion först ` + `(gnubok_match_transaction_to_invoice eller "Matcha mot transaktion" i Dokumentinkorgen).`, ) } // Resolve matched-tx dates/amounts for the period envelope + an aggregate // total. preview_data carries only aggregate counts + sum: no per-item // PII (GDPR Art.25), same rationale as gnubok_bulk_book_transactions. const txIds = bookable.map((it) => it.matched_transaction_id as string) const { data: txs } = await supabase .from('transactions') .select('id, date, amount, currency, amount_sek, exchange_rate') .in('id', txIds) .eq('company_id', companyId) const txDates = (txs ?? []).map((t) => t.date as string).filter(Boolean).sort() const earliestDate = txDates[0] const totalSek = (txs ?? []).reduce((s, t) => { const cur = String(t.currency ?? 'SEK').toUpperCase() const sek = cur === 'SEK' ? Math.abs(Number(t.amount)) : Math.abs(Number(t.amount_sek ?? Number(t.amount) * Number(t.exchange_rate ?? 1))) return s + (Number.isFinite(sek) ? sek : 0) }, 0) return stagePendingOperation(supabase, companyId, userId, 'bulk_book_inbox_items', `Bulkbokför ${bookable.length} underlag`, { // Stage only the bookable items: the executor re-checks each and // skips any that changed state between staging and approval. item_ids: bookable.map((it) => it.id as string), category: args.category, vat_treatment: args.vat_treatment ?? null, vat_amount: vatAmount ?? null, notes: notes ?? null, allow_duplicate: args.allow_duplicate === true, dimensions: resolvedDimensions && Object.keys(resolvedDimensions).length > 0 ? resolvedDimensions : null, }, { item_count: itemIds.length, bookable_count: bookable.length, will_skip_count: notMatched + alreadyBooked + notFound, not_matched: notMatched, already_booked: alreadyBooked, not_found: notFound, total_sek: Math.round(totalSek * 100) / 100, category: args.category, vat_treatment: args.vat_treatment ?? null, ...(resolvedDimensions && Object.keys(resolvedDimensions).length > 0 ? { dimensions: resolvedDimensions } : {}), // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), }, actor, { description: 'After approval each underlag is booked against its matched transaction. Verify with gnubok_list_inbox_items or gnubok_query_journal.', tool: 'gnubok_list_inbox_items', }, earliestDate ? { dateForPeriodCheck: earliestDate } : {}, ) }, }, { name: 'gnubok_find_voucher_candidates_for_invoice', title: 'Find Voucher Candidates (Invoice)', description: "List posted verifikat that could be this invoice's payment (faktureringsmetoden: credit 1510; kontantmetoden: debit 19xx). Call before gnubok_link_invoice_to_voucher to mark the faktura paid (no new bokföring).", inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the invoice to find candidates for' }, limit: { type: 'number', description: 'Max candidates to return (default 10, max 50)' }, }, required: ['invoice_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string' }, invoice_status: { type: 'string' }, candidates: { type: 'array', items: { type: 'object' } }, }, required: ['invoice_id', 'candidates'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const limit = Math.min(Math.max(1, Number(args.limit) || 10), 50) const { data: invoice, error } = await supabase .from('invoices') .select( 'id, invoice_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)' ) .eq('id', invoiceId) .eq('company_id', companyId) .single() if (error || !invoice) throw new Error('Invoice not found') if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) { return { invoice_id: invoiceId, invoice_status: invoice.status, candidates: [], } } const candidates = await findMatchingVouchersForInvoice( supabase, companyId, invoice as never, { limit }, ) return { invoice_id: invoiceId, invoice_status: invoice.status, candidates, } }, }, { name: 'gnubok_link_invoice_to_voucher', title: 'Link Invoice to Voucher', description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Kör gnubok_find_voucher_candidates_for_invoice först. Stages for approval.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the invoice to mark paid' }, journal_entry_id: { type: 'string', description: 'UUID of the existing posted verifikat to link' }, notes: { type: 'string', description: 'Optional note stored on the invoice_payments row' }, }, required: ['invoice_id', 'journal_entry_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string const journalEntryId = args.journal_entry_id as string const notes = (args.notes as string | undefined) ?? undefined if (!invoiceId || !journalEntryId) { throw new Error('invoice_id and journal_entry_id are required') } const { data: invoice, error: invErr } = await supabase .from('invoices') .select( 'id, invoice_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, customer_id, customer:customers(id, name)' ) .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invErr || !invoice) throw new Error('Invoice not found') if (!['sent', 'overdue', 'partially_paid'].includes(invoice.status)) { throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)') } const validation = await validateVoucherForInvoiceLink( supabase, companyId, invoice as never, journalEntryId, ) if (!validation.ok) { throw new Error( `${validation.code}${validation.details ? `: ${JSON.stringify(validation.details)}` : ''}`, ) } const voucherLabel = validation.voucher.voucher_series && validation.voucher.voucher_number != null ? `${validation.voucher.voucher_series}-${validation.voucher.voucher_number}` : journalEntryId.slice(0, 8) return stagePendingOperation( supabase, companyId, userId, 'link_invoice_voucher', `Länka verifikat ${voucherLabel} → faktura ${invoice.invoice_number ?? invoiceId.slice(0, 8)}`, { invoice_id: invoiceId, journal_entry_id: journalEntryId, notes }, { invoice_number: invoice.invoice_number, invoice_currency: invoice.currency, invoice_remaining: invoice.remaining_amount, voucher_label: voucherLabel, voucher_date: validation.voucher.entry_date, voucher_description: validation.voucher.description, ar_credit_amount: validation.arCreditAmount, payment_amount: validation.paymentAmount, will_be_fully_paid: validation.isFullyPaid, remaining_after: validation.remainingAfter, customer_name: (invoice.customer as unknown as { name?: string } | null)?.name ?? null, }, actor, { description: 'After approval the invoice transitions to paid (or partially_paid). No new verifikat is created: the existing voucher is the payment posting.', tool: 'gnubok_get_ar_ledger', }, ) }, }, { name: 'gnubok_find_voucher_candidates_for_supplier_invoice', title: 'Find Voucher Candidates (Supplier Invoice)', description: 'List posted verifikat that debit leverantörsskuld (2440) and could be this supplier invoice\'s payment. Call before gnubok_link_supplier_invoice_to_voucher to mark the leverantörsfaktura paid (no new bokföring).', inputSchema: { type: 'object', additionalProperties: false, properties: { supplier_invoice_id: { type: 'string', description: 'UUID of the supplier invoice to find candidates for' }, limit: { type: 'number', description: 'Max candidates to return (default 10, max 50)' }, }, required: ['supplier_invoice_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { supplier_invoice_id: { type: 'string' }, invoice_status: { type: 'string' }, candidates: { type: 'array', items: { type: 'object' } }, }, required: ['supplier_invoice_id', 'candidates'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const supplierInvoiceId = args.supplier_invoice_id as string if (!supplierInvoiceId) throw new Error('supplier_invoice_id is required') const limit = Math.min(Math.max(1, Number(args.limit) || 10), 50) const { data: invoice, error } = await supabase .from('supplier_invoices') .select( 'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)' ) .eq('id', supplierInvoiceId) .eq('company_id', companyId) .single() if (error || !invoice) throw new Error('Supplier invoice not found') if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) { return { supplier_invoice_id: supplierInvoiceId, invoice_status: invoice.status, candidates: [], } } const candidates = await findMatchingVouchersForSupplierInvoice( supabase, companyId, invoice as never, { limit }, ) return { supplier_invoice_id: supplierInvoiceId, invoice_status: invoice.status, candidates, } }, }, { name: 'gnubok_link_supplier_invoice_to_voucher', title: 'Link Supplier Invoice to Voucher', description: 'Markera en leverantörsfaktura som betald via länk till en befintlig verifikation som debiterar leverantörsskuld (2440). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_supplier_invoice först. Stages.', inputSchema: { type: 'object', additionalProperties: false, properties: { supplier_invoice_id: { type: 'string', description: 'UUID of the supplier invoice to mark paid' }, journal_entry_id: { type: 'string', description: 'UUID of the existing posted verifikat to link' }, notes: { type: 'string', description: 'Optional note stored on the supplier_invoice_payments row' }, }, required: ['supplier_invoice_id', 'journal_entry_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const supplierInvoiceId = args.supplier_invoice_id as string const journalEntryId = args.journal_entry_id as string const notes = (args.notes as string | undefined) ?? undefined if (!supplierInvoiceId || !journalEntryId) { throw new Error('supplier_invoice_id and journal_entry_id are required') } const { data: invoice, error: invErr } = await supabase .from('supplier_invoices') .select( 'id, supplier_invoice_number, arrival_number, status, currency, total, paid_amount, remaining_amount, due_date, paid_at, exchange_rate, supplier_id, supplier:suppliers(id, name)' ) .eq('id', supplierInvoiceId) .eq('company_id', companyId) .single() if (invErr || !invoice) throw new Error('Supplier invoice not found') if (!['registered', 'approved', 'overdue', 'partially_paid'].includes(invoice.status)) { throw new Error('Supplier invoice is not in a matchable state (must be registered, approved, overdue, or partially_paid)') } const validation = await validateVoucherForSupplierInvoiceLink( supabase, companyId, invoice as never, journalEntryId, ) if (!validation.ok) { throw new Error( `${validation.code}${validation.details ? `: ${JSON.stringify(validation.details)}` : ''}`, ) } const voucherLabel = validation.voucher.voucher_series && validation.voucher.voucher_number != null ? `${validation.voucher.voucher_series}-${validation.voucher.voucher_number}` : journalEntryId.slice(0, 8) return stagePendingOperation( supabase, companyId, userId, 'link_supplier_invoice_voucher', `Länka verifikat ${voucherLabel} → leverantörsfaktura ${invoice.supplier_invoice_number ?? supplierInvoiceId.slice(0, 8)}`, { supplier_invoice_id: supplierInvoiceId, journal_entry_id: journalEntryId, notes }, { supplier_invoice_number: invoice.supplier_invoice_number, invoice_currency: invoice.currency, invoice_remaining: invoice.remaining_amount, voucher_label: voucherLabel, voucher_date: validation.voucher.entry_date, voucher_description: validation.voucher.description, ap_debit_amount: validation.apDebitAmount, payment_amount: validation.paymentAmount, will_be_fully_paid: validation.isFullyPaid, remaining_after: validation.remainingAfter, supplier_name: (invoice.supplier as unknown as { name?: string } | null)?.name ?? null, }, actor, { description: 'After approval the supplier invoice transitions to paid (or partially_paid). No new verifikat is created: the existing voucher is the payment posting.', tool: 'gnubok_get_supplier_ledger', }, ) }, }, { name: 'gnubok_auto_match_period', title: 'Auto-Match Period Income', description: "Bulk reconciliation: scan unmatched income in a date range and propose invoice matches with confidence + reasoning. dry_run=true (default) previews; dry_run=false stages matches above confidence_threshold.", inputSchema: { type: 'object', additionalProperties: false, properties: { date_from: { type: 'string', description: 'Period start YYYY-MM-DD' }, date_to: { type: 'string', description: 'Period end YYYY-MM-DD' }, confidence_threshold: { type: 'number', description: 'Minimum confidence to propose (0..1, default 0.9). Lower for more matches; raise for safety.' }, dry_run: { type: 'boolean', description: 'If true (default), preview proposals without staging. If false, stage each above-threshold match as a pending operation.' }, max_transactions: { type: 'number', description: 'Cap on transactions to process this call (default 100, max 500). Use multiple calls or narrower date ranges for very large periods.' }, }, required: ['date_from', 'date_to'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { dry_run: { type: 'boolean' }, confidence_threshold: { type: 'number' }, scanned_transactions: { type: 'number' }, proposed_matches: { type: 'number' }, below_threshold: { type: 'number' }, no_match_found: { type: 'number' }, truncated: { type: 'boolean' }, proposals: { type: 'array', items: { type: 'object' } }, staged_count: { type: 'number' }, stage_failures: { type: 'array', items: { type: 'object' } }, }, required: ['dry_run', 'scanned_transactions', 'proposed_matches', 'proposals'], }, annotations: { readOnlyHint: false, // can stage when dry_run=false destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const dateFrom = args.date_from as string const dateTo = args.date_to as string if (!dateFrom || !dateTo) throw new Error('date_from and date_to are required') const confidenceThreshold = typeof args.confidence_threshold === 'number' ? Math.max(0, Math.min(1, args.confidence_threshold)) : 0.9 const dryRun = args.dry_run !== false const maxTransactions = Math.min(Math.max(1, Number(args.max_transactions) || 100), 500) // Fetch unmatched income transactions in window. We require positive // amount because findMatchingInvoices only matches income; expenses are // out of scope for this tool. const { data: transactions, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, date, reference, journal_entry_id, invoice_id') .eq('company_id', companyId) .gte('date', dateFrom) .lte('date', dateTo) .gt('amount', 0) .is('journal_entry_id', null) .is('invoice_id', null) .order('date', { ascending: true }) .limit(maxTransactions + 1) if (txError) throw new Error(`Failed to fetch transactions: ${txError.message}`) const txList = (transactions ?? []).slice(0, maxTransactions) const truncated = (transactions ?? []).length > maxTransactions type Proposal = { transaction_id: string transaction_date: string transaction_amount: number transaction_currency: string transaction_description: string invoice_id: string invoice_number: string | null invoice_total: number customer_name: string | null confidence: number match_reason: string decision: 'propose' | 'below_threshold' | 'no_match' } const proposals: Proposal[] = [] let belowThreshold = 0 let noMatchFound = 0 for (const tx of txList) { const matches = await findMatchingInvoices( supabase, companyId, tx as never, ) if (matches.length === 0) { noMatchFound++ continue } const best = matches[0] const baseProposal: Omit = { transaction_id: tx.id as string, transaction_date: tx.date as string, transaction_amount: Number(tx.amount) || 0, transaction_currency: tx.currency as string, transaction_description: (tx.merchant_name as string) || (tx.description as string) || '', invoice_id: best.invoice.id, invoice_number: best.invoice.invoice_number, invoice_total: best.invoice.total, customer_name: (best.invoice.customer as { name?: string } | undefined)?.name ?? null, confidence: Math.round(best.confidence * 1000) / 1000, match_reason: best.matchReason, } if (best.confidence < confidenceThreshold) { proposals.push({ ...baseProposal, decision: 'below_threshold' as const }) belowThreshold++ } else { proposals.push({ ...baseProposal, decision: 'propose' as const }) } } const proposed = proposals.filter((p) => p.decision === 'propose') // Dry-run path: return proposals with reasoning, no side-effects if (dryRun) { return { dry_run: true, confidence_threshold: confidenceThreshold, scanned_transactions: txList.length, proposed_matches: proposed.length, below_threshold: belowThreshold, no_match_found: noMatchFound, truncated, proposals, staged_count: 0, stage_failures: [], } } // Commit path: stage each above-threshold match through pending_operations. // Per-item failure isolation: one bad match doesn't kill the rest. const stageFailures: { transaction_id: string; invoice_id: string; error: string }[] = [] let stagedCount = 0 for (const p of proposed) { try { await stagePendingOperation( supabase, companyId, userId, 'match_transaction_invoice', `Matcha: ${p.transaction_description || p.transaction_id} → ${p.invoice_number}`, { transaction_id: p.transaction_id, invoice_id: p.invoice_id }, { transaction_description: p.transaction_description, transaction_amount: p.transaction_amount, transaction_currency: p.transaction_currency, invoice_number: p.invoice_number, invoice_total: p.invoice_total, customer_name: p.customer_name, auto_match_confidence: p.confidence, auto_match_reason: p.match_reason, }, actor, ) stagedCount++ } catch (err) { stageFailures.push({ transaction_id: p.transaction_id, invoice_id: p.invoice_id, error: err instanceof Error ? err.message : 'Unknown stage error', }) } } return { dry_run: false, confidence_threshold: confidenceThreshold, scanned_transactions: txList.length, proposed_matches: proposed.length, below_threshold: belowThreshold, no_match_found: noMatchFound, truncated, proposals, staged_count: stagedCount, stage_failures: stageFailures, } }, }, // ── Fiscal Periods ─────────────────────────────────────────── { name: 'gnubok_list_fiscal_periods', title: 'List Fiscal Periods', description: 'List all fiscal periods (räkenskapsperioder) with status: active (open), locked (no new entries), or closed (year-end completed).', inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', additionalProperties: false, properties: { periods: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['periods', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { const { data, error } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at, opening_balances_set') .eq('company_id', companyId) .order('period_start', { ascending: false }) if (error) throw new Error(`Database error: ${error.message}`) const periods = (data ?? []).map((p) => ({ id: p.id, name: p.name, period_start: p.period_start, period_end: p.period_end, opening_balances_set: p.opening_balances_set, status: p.is_closed ? 'closed' : p.locked_at ? 'locked' : 'active', })) return { periods, count: periods.length } }, }, // ── Reconciliation ─────────────────────────────────────────── { name: 'gnubok_get_reconciliation_status', title: 'Bank Reconciliation Status', description: 'Bank reconciliation for one cash account: matched/unmatched counts, bank vs ledger balance, difference. Defaults to 1930, or the primary cash account if there is no 1930; pass account_number for 1940/1932 etc. Optional date range.', inputSchema: { type: 'object', additionalProperties: false, properties: { date_from: { type: 'string', description: 'Start date YYYY-MM-DD' }, date_to: { type: 'string', description: 'End date YYYY-MM-DD' }, account_number: { type: 'string', description: 'Cash-account BAS code to reconcile, e.g. "1940". Defaults to "1930".', }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const dateFrom = args.date_from as string | undefined const dateTo = args.date_to as string | undefined // Passed through as-is, undefined included: an omitted account_number is // "the company's bank account", which resolves to 1930 and, for a company // with no 1930 row, to its primary cash account. Substituting a literal // '1930' here would instead make an unknown account an error case. const accountNumber = args.account_number as string | undefined const { status } = await getScopedReconciliationStatus( supabase, companyId, dateFrom, dateTo, accountNumber, ) return status }, }, // ── Document Inbox Tools ──────────────────────────────────── { name: 'gnubok_create_document_upload', title: 'Create Document Upload', description: 'Create a short-lived URL for a model-free document upload. PUT the raw file bytes (max 10 MB) to upload_url, then call gnubok_complete_document_upload with the same upload_id and file_name.', inputSchema: { type: 'object', additionalProperties: false, properties: { file_name: { type: 'string', minLength: 1, maxLength: 255, description: 'File name with extension, for example "faktura.pdf"', }, mime_type: { type: 'string', enum: [...MCP_DOCUMENT_MIME_TYPES], description: 'MIME type. Optional when it can be inferred from the file extension.', }, }, required: ['file_name'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { upload_id: { type: 'string' }, upload_url: { type: 'string' }, expires_at: { type: 'string' }, }, required: ['upload_id', 'upload_url', 'expires_at'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const fileName = args.file_name as string // Validation only: reject unsupported types before handing out a signed // URL. The resolved value is re-derived identically at complete time. resolveMcpDocumentMimeType(fileName, args.mime_type) const uploadId = crypto.randomUUID() const reservation = await createPendingDocumentUpload( supabase, companyId, userId, uploadId, fileName, ) return { upload_id: reservation.uploadId, upload_url: reservation.signedUrl, expires_at: reservation.expiresAt, } }, }, { name: 'gnubok_complete_document_upload', title: 'Complete Document Upload', description: 'Validate and archive bytes sent to the URL from gnubok_create_document_upload, run AI extraction and create the inbox item. Idempotent: safe to retry with the same upload_id.', inputSchema: { type: 'object', additionalProperties: false, properties: { upload_id: { type: 'string', format: 'uuid', description: 'Reserved UUID returned by gnubok_create_document_upload', }, file_name: { type: 'string', minLength: 1, maxLength: 255, description: 'The same file name used to create the upload URL', }, mime_type: { type: 'string', enum: [...MCP_DOCUMENT_MIME_TYPES], description: 'MIME type. Optional when it can be inferred from the file extension.', }, }, required: ['upload_id', 'file_name'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string' }, inbox_item_id: { type: 'string' }, status: { type: 'string' }, extracted_data: { type: 'object' }, matched_supplier_id: { type: 'string' }, }, required: ['document_id', 'inbox_item_id', 'status'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const uploadId = args.upload_id as string const fileName = args.file_name as string const mimeType = resolveMcpDocumentMimeType(fileName, args.mime_type) const existingInbox = await findCompletedDocumentInboxItem( supabase, companyId, userId, uploadId, ) if (existingInbox) return existingInbox const completed = await completePendingDocumentUpload( supabase, companyId, userId, uploadId, fileName, mimeType, ) return createDocumentInboxItem( supabase, companyId, userId, completed.document.id, fileName, mimeType, Buffer.from(completed.buffer), uploadId, ) }, }, { name: 'gnubok_upload_document', title: 'Upload Document to Inbox', description: 'Legacy inline-base64 upload for small files (max 10 MB). Prefer gnubok_create_document_upload so raw bytes bypass the model. Runs AI field extraction: requires the AI capability.', inputSchema: { type: 'object', additionalProperties: false, properties: { file_name: { type: 'string', description: 'File name with extension (e.g. "faktura.pdf")' }, file_content_base64: { type: 'string', description: 'Base64-encoded file content' }, mime_type: { type: 'string', description: 'MIME type (optional, inferred from extension)' }, }, required: ['file_name', 'file_content_base64'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string' }, inbox_item_id: { type: 'string' }, status: { type: 'string' }, extracted_data: { type: 'object' }, matched_supplier_id: { type: 'string' }, }, required: ['document_id', 'inbox_item_id', 'status'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const fileName = args.file_name as string const base64Content = args.file_content_base64 as string const mimeType = resolveMcpDocumentMimeType(fileName, args.mime_type) const buffer = Buffer.from(base64Content, 'base64') if (buffer.byteLength > MAX_DOCUMENT_SIZE) { throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`) } const doc = await uploadDocument(supabase, userId, companyId, { name: fileName, buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength), type: mimeType, }, { upload_source: 'api' }) return createDocumentInboxItem( supabase, companyId, userId, doc.id, fileName, mimeType, buffer, ) }, }, { name: 'gnubok_list_inbox_items', title: 'List Inbox Items', description: 'List document inbox items, including each original file_name. `processed` covers all terminal links (transaction, supplier invoice, journal entry); booked receipts count as done. unprocessed_only=true returns docs still needing handling.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['received', 'error'], description: 'Filter by status' }, unprocessed_only: { type: 'boolean', description: 'When true, only return items with no terminal link yet (not matched to a transaction, supplier invoice, or journal entry), i.e. documents that still need handling. Default false.' }, limit: { type: 'number', description: 'Max results (default 20, max 50)' }, cursor: { type: 'string', maxLength: 100, pattern: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:\\d{2})(?:__[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})?$', description: 'Composite "__" from previous page (exclusive). Pass next_cursor verbatim.', }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { items: { type: 'array', items: { type: 'object', properties: { file_name: { type: ['string', 'null'], description: 'Original document file name, or null when the inbox item has no document', }, }, required: ['file_name'], }, }, count: { type: 'number' }, next_cursor: { type: 'string', description: 'Pass as cursor on next call. Absent = no more pages.' }, }, required: ['items', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) const status = args.status as string | undefined const unprocessedOnly = args.unprocessed_only === true const cursor = typeof args.cursor === 'string' ? args.cursor : null // Composite cursor: "__". Falls back to plain timestamp // for backward compatibility with older callers. let cursorTs: string | null = null let cursorId: string | null = null if (cursor) { const sep = cursor.indexOf('__') if (sep === -1) { cursorTs = cursor } else { cursorTs = cursor.slice(0, sep) cursorId = cursor.slice(sep + 2) } } if (cursorTs && !z.string().datetime({ offset: true }).safeParse(cursorTs).success) { throw new Error('Invalid cursor timestamp. Pass next_cursor verbatim.') } if (cursorId && !z.string().uuid().safeParse(cursorId).success) { throw new Error('Invalid cursor inbox item ID. Pass next_cursor verbatim.') } const fetchSize = unprocessedOnly ? 200 : limit let query = supabase .from('invoice_inbox_items') .select(` id, status, source, created_at, extracted_data, matched_supplier_id, matched_transaction_id, created_supplier_invoice_id, created_journal_entry_id, email_from, email_subject, error_message, document_attachments(file_name) `) .eq('company_id', companyId) .order('created_at', { ascending: false }) .order('id', { ascending: false }) // Fetch a wider window when filtering client-side so the limit // applies to the post-filter set rather than truncating before it. .limit(fetchSize) if (status) query = query.eq('status', status) if (cursorTs && cursorId) { query = query.or( `created_at.lt.${cursorTs},and(created_at.eq.${cursorTs},id.lt.${cursorId})` ) } else if (cursorTs) { query = query.lt('created_at', cursorTs) } const { data, error } = await query if (error) throw new Error(`Database error: ${error.message}`) const mapped = (data || []).map((item) => { const extracted = item.extracted_data as Record | null let vendorName: string | null = null let amount: number | null = null let invoiceDate: string | null = null if (extracted) { const supplier = extracted.supplier as Record | undefined const invoice = extracted.invoice as Record | undefined const totals = extracted.totals as Record | undefined vendorName = (supplier?.name as string) || null amount = (totals?.total as number) || null invoiceDate = (invoice?.invoiceDate as string) || null } // An item is "processed" once it has ANY terminal link: matched to a // bank transaction, converted to a supplier invoice, or booked // directly to a journal entry. Surfacing only the supplier fields // (as before) made receipts booked against bank transactions look // loose: and risked the agent flagging them as duplicates. const processed = !!( item.matched_transaction_id || item.created_supplier_invoice_id || item.created_journal_entry_id ) return { id: item.id, status: item.status, source: item.source, created_at: item.created_at, file_name: item.document_attachments?.[0]?.file_name ?? null, vendor_name: vendorName, amount, invoice_date: invoiceDate, processed, matched_supplier_id: item.matched_supplier_id, matched_transaction_id: item.matched_transaction_id, created_supplier_invoice_id: item.created_supplier_invoice_id, created_journal_entry_id: item.created_journal_entry_id, email_from: item.email_from, email_subject: item.email_subject, error_message: item.error_message, } }) const filtered = unprocessedOnly ? mapped.filter((i) => !i.processed) : mapped const items = filtered.slice(0, limit) // A full returned page continues after its last item. When client-side // filtering yields a short page from a full scan window, continue after // the last inspected row so older unprocessed items remain reachable. let nextCursor: string | null = null if (items.length === limit) { const last = items[items.length - 1] nextCursor = `${last.created_at}__${last.id}` } else if (data && data.length === fetchSize) { const last = data[data.length - 1] nextCursor = `${last.created_at}__${last.id}` } return { items, count: items.length, ...(nextCursor ? { next_cursor: nextCursor } : {}), } }, }, { name: 'gnubok_get_inbox_item', title: 'Get Inbox Item', description: 'Get a single inbox item with complete extracted data, supplier match, email metadata, and timestamps.', inputSchema: { type: 'object', additionalProperties: false, properties: { inbox_item_id: { type: 'string', description: 'UUID of the inbox item' }, }, required: ['inbox_item_id'], }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const id = args.inbox_item_id as string const { data, error } = await supabase .from('invoice_inbox_items') .select('*, document_attachments(id, file_name, mime_type, file_size_bytes, created_at)') .eq('id', id) .eq('company_id', companyId) .single() if (error) throw new Error(`Database error: ${error.message}`) if (!data) throw new Error('Inbox item not found') return data }, }, { name: 'gnubok_create_supplier_invoice_from_inbox', title: 'Create Supplier Invoice from Inbox', description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX + dimension tags, attaches the document. Stages for human review; honors dry_run. Unresolved supplier → staged:false + candidates + next.", inputSchema: { type: 'object', additionalProperties: false, properties: { inbox_item_id: { type: 'string', description: 'UUID of the inbox item to convert' }, supplier_id_override: { type: 'string', description: 'Force this supplier UUID instead of the matched/extracted one' }, vat_treatment_override: { type: 'string', enum: ['standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt'], description: 'Override extracted VAT treatment' }, invoice_date_override: { type: 'string', description: 'Override extracted invoice date (YYYY-MM-DD). Use when OCR misses the date.' }, due_date_override: { type: 'string', description: 'Override extracted due date (YYYY-MM-DD)' }, line_overrides: { type: 'array', description: 'Per-line overrides (1-based line_number): account_number wins over accountSuggestion and supplier default; dimensions tags that line.', items: { type: 'object', additionalProperties: false, properties: { line_number: { type: 'number', description: '1-based index matching items_preview' }, account_number: { type: 'string', description: 'BAS account number for this line (e.g. "6420")' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, for this line. Wins per key over default_dimensions.', }, }, required: ['line_number'], }, }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag keyed by SIE dim no, value = code OR name, e.g. {"1":"KS01","6":"Villa Almgren"}. Applied to every line not setting the key. Unknown values rejected: never auto-created.', }, notes: { type: 'string', description: 'Optional notes appended to the supplier invoice' }, dry_run: { type: 'boolean', description: 'If true, return the assembled payload without staging (default false)' }, idempotency_key: { type: 'string', description: 'UUID. Repeat calls with same key + payload return cached response.' }, }, required: ['inbox_item_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const inboxItemId = args.inbox_item_id as string if (!inboxItemId) throw new Error('inbox_item_id is required') const dryRun = args.dry_run === true const idempotencyKey = args.idempotency_key as string | undefined // Fetch the inbox item with the attached source document const { data: inbox, error: inboxErr } = await supabase .from('invoice_inbox_items') .select('id, status, extracted_data, matched_supplier_id, created_supplier_invoice_id, document_id') .eq('id', inboxItemId) .eq('company_id', companyId) .single() if (inboxErr || !inbox) throw new Error('Inbox item not found') if (inbox.created_supplier_invoice_id) { throw new Error(`Inbox item already converted to supplier invoice ${inbox.created_supplier_invoice_id}`) } const extracted = (inbox.extracted_data as Record | null) ?? null if (!extracted) throw new Error('Inbox item has no extracted_data: re-run extraction first') const supplierExt = extracted.supplier as Record | undefined const invoiceExt = extracted.invoice as Record | undefined const totalsExt = extracted.totals as Record | undefined const lineItemsExt = (extracted.lineItems as Array> | undefined) ?? [] // Resolve supplier: explicit override > matched > org_number lookup > name lookup const supplierIdOverride = args.supplier_id_override as string | undefined let supplierId: string | null = supplierIdOverride ?? (inbox.matched_supplier_id as string | null) ?? null let supplierResolution: 'override' | 'matched' | 'lookup_org_number' | 'lookup_name' | 'unresolved' = supplierIdOverride ? 'override' : inbox.matched_supplier_id ? 'matched' : 'unresolved' if (!supplierId) { const orgNumber = supplierExt?.organizationNumber as string | undefined const supplierName = supplierExt?.name as string | undefined if (orgNumber) { const { data } = await supabase .from('suppliers') .select('id') .eq('company_id', companyId) .eq('org_number', orgNumber) .maybeSingle() if (data) { supplierId = data.id supplierResolution = 'lookup_org_number' } } if (!supplierId && supplierName) { const { data } = await supabase .from('suppliers') .select('id') .eq('company_id', companyId) .ilike('name', supplierName) .maybeSingle() if (data) { supplierId = data.id supplierResolution = 'lookup_name' } } } if (!supplierId) { // Structured resolution failure instead of a dead end (P1-4, // dev_docs/mcp_optimization_plan.md): a thrown error here stops the // whole inbox pipeline for small ad hoc vendors. Return staged:false // with near-miss candidates the agent can pass as supplier_id_override, // or a create-supplier next hint when nothing is close. Fuzzy scores // never auto-resolve: the agent/human confirms against the underlag. const extractedName = (supplierExt?.name as string | undefined) ?? null const extractedOrg = (supplierExt?.organizationNumber as string | undefined) ?? null const CANDIDATE_POOL_CAP = 500 const { data: companySuppliers } = await supabase .from('suppliers') .select('id, name, org_number') .eq('company_id', companyId) .limit(CANDIDATE_POOL_CAP) const candidates = findSupplierCandidates( (companySuppliers ?? []) as { id: string; name: string; org_number: string | null }[], extractedName, extractedOrg, ) const best = candidates[0] // No silent caps: past the pool cap the right supplier may exist yet // be absent from candidates: say so instead of implying full coverage. const poolTruncated = (companySuppliers?.length ?? 0) >= CANDIDATE_POOL_CAP return { staged: false, risk_level: getRiskLevel('create_supplier_invoice_from_inbox'), actor: actor ?? { type: 'user' }, message: (best ? `Could not resolve supplier "${extractedName ?? 'unknown'}" exactly: ${candidates.length} near-miss candidate(s) in preview.candidates. Verify against the underlag, then retry with supplier_id_override; or create the supplier first.` : `Could not resolve supplier "${extractedName ?? 'unknown'}" (org: ${extractedOrg ?? 'unknown'}) and no similar supplier exists. Create it with gnubok_create_supplier, then retry with supplier_id_override.`) + (poolTruncated ? ` Note: candidate search covered only the first ${CANDIDATE_POOL_CAP} suppliers: the pool was truncated.` : ''), preview: { supplier_resolution: 'unresolved', unresolved_supplier: { extracted_name: extractedName, extracted_org_number: extractedOrg, }, candidates, candidate_pool_truncated: poolTruncated, }, next: best ? { description: `Closest existing supplier: "${best.name}" (score ${best.score}). If it matches the underlag, retry with this supplier_id_override.`, tool: 'gnubok_create_supplier_invoice_from_inbox', args: { inbox_item_id: inboxItemId, supplier_id_override: best.supplier_id }, } : { description: 'Create the supplier, approve it, then retry this tool with supplier_id_override set to the new supplier id.', tool: 'gnubok_create_supplier', args: { ...(extractedName ? { name: extractedName } : {}), ...(extractedOrg ? { org_number: extractedOrg } : {}), }, }, } } // Fetch supplier defaults so line items can inherit default_expense_account // when neither the extraction nor the agent provided an accountSuggestion. // Doubles as existence/tenancy validation: every resolution path (and // especially supplier_id_override, which the unresolved next-hint now // actively promotes) must point at a supplier in THIS company, or the // staged operation would fail opaquely at commit time instead. const { data: resolvedSupplier } = await supabase .from('suppliers') .select('id, default_expense_account') .eq('id', supplierId) .eq('company_id', companyId) .single() if (!resolvedSupplier) { throw new Error( supplierResolution === 'override' ? `supplier_id_override ${supplierId} does not match any supplier in this company. Use a supplier_id from preview.candidates or gnubok_list_suppliers.` : `Resolved supplier ${supplierId} no longer exists in this company: re-run extraction or pass supplier_id_override.`, ) } const supplierDefaultExpenseAccount = resolvedSupplier.default_expense_account ?? null // Assemble core invoice fields const currency = (invoiceExt?.currency as string) || 'SEK' for (const key of ['invoice_date_override', 'due_date_override'] as const) { const value = args[key] as string | undefined if (value !== undefined && !ISO_DATE_RE.test(value)) { throw new Error(`${key} must be an ISO date (YYYY-MM-DD), got "${value}"`) } } const invoiceDate = (args.invoice_date_override as string | undefined) ?? (invoiceExt?.invoiceDate as string) ?? null const dueDate = (args.due_date_override as string | undefined) ?? (invoiceExt?.dueDate as string | undefined) ?? null const supplierInvoiceNumber = (invoiceExt?.invoiceNumber as string) || '' if (!invoiceDate) throw new Error('Extracted invoice has no invoice date') if (!supplierInvoiceNumber) throw new Error('Extracted invoice has no invoice number') const total = Number(totalsExt?.total) || 0 const subtotal = Number(totalsExt?.subtotal) || 0 // VAT treatment: explicit override wins, else heuristic from extracted data const vatTreatment = (args.vat_treatment_override as string | undefined) ?? (invoiceExt?.vatTreatment as string | undefined) ?? 'standard_25' // FX: if non-SEK, fetch rate at fakturadatum (best-effort; agent can re-stage on failure) let exchangeRate: number | null = null if (currency !== 'SEK' && invoiceDate) { try { const result = await fetchExchangeRate(currency as Currency, new Date(invoiceDate)) exchangeRate = result?.rate ?? null } catch { exchangeRate = null // Agent will be informed via preview; can override later } } // Build lookups for per-line overrides keyed by 1-based line number. const rawLineOverrides = (args.line_overrides as Array<{ line_number: number; account_number?: string; dimensions?: unknown }> | undefined) ?? [] const lineOverrideMap = new Map( rawLineOverrides.filter((o) => o.account_number).map((o) => [o.line_number, o.account_number as string]), ) const lineDimensionsMap = new Map( rawLineOverrides.map((o, i) => [o.line_number, parseDimensionsArg(o.dimensions, `line_overrides[${i}].dimensions`)]), ) // Resolve-don't-select: parse the invoice-level default bag + each line's // own bag, then resolve codes AND natural-language names against the // registry in ONE pass (zero queries when nothing is tagged; free-text // passthrough while dimensions_enabled is off). The resolved default is // staged top-level; each item keeps only its own resolved bag: the // executor merges item-over-default at commit time. const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, [defaultDimensions, ...lineItemsExt.map((_li, idx) => lineDimensionsMap.get(idx + 1))], ) const resolvedDefaultDimensions = resolvedDimBags[0] // Translate extracted line items into the supplier_invoice_items shape. // Priority: line_overrides → per-line accountSuggestion → supplier.default_expense_account → 4000. const lineItems = lineItemsExt.map((li, idx) => { const lineNumber = idx + 1 const dimensions = resolvedDimBags[idx + 1] const lineTotal = Number(li.line_total ?? li.lineTotal ?? li.amount) || 0 // The AI extraction contract (ExtractionSchema) carries vatRate as a // percent integer (25, 12, 6) while supplier_invoice_items stores a // decimal fraction (0.25): normalize at this boundary or vat_rate 25 // books 2500 % VAT downstream (issue #310). Foreign rates (19, 20) // map to 0 per the extraction contract: the strict Swedish allowlist // applies when converting to a supplier invoice. const vatRate = normalizeVatRateToDecimal(li.vat_rate ?? li.vatRate) // Real extractions carry no per-line VAT amount: derive it from the // normalized rate so the staged header vat_amount (summed below) is // honest instead of 0, which would gate the whole 2641 posting off. const rawVatAmount = li.vat_amount ?? li.vatAmount const vatAmount = rawVatAmount == null ? roundOre(lineTotal * vatRate) : Number(rawVatAmount) || 0 return { line_number: lineNumber, description: (li.description as string) ?? `Position ${lineNumber}`, quantity: Number(li.quantity) || 1, unit: (li.unit as string) ?? 'st', unit_price: Number(li.unit_price ?? li.unitPrice ?? li.amount) || 0, line_total: lineTotal, account_number: lineOverrideMap.get(lineNumber) ?? (li.accountSuggestion as string | null) ?? supplierDefaultExpenseAccount ?? '4000', vat_rate: vatRate, vat_amount: vatAmount, ...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}), } }) // Derive from the actual per-line VAT rather than trusting // totalsExt.vat: that header figure comes straight from OCR/agent- // supplied extracted_data and is never reconciled against lineItems. // Per-line VAT customization (edited rate/amount on a line without // also fixing up the document totals) used to leave this at a stale // or zero value, and createSupplierInvoiceRegistrationEntry gates the // whole 2641 posting on invoice.vat_amount > 0: a stale header meant // the correct per-line VAT was silently never booked. const vatAmount = lineItems.reduce((sum, li) => sum + li.vat_amount, 0) const params = { inbox_item_id: inboxItemId, supplier_id: supplierId, document_id: inbox.document_id, supplier_invoice_number: supplierInvoiceNumber, invoice_date: invoiceDate, due_date: dueDate, currency, exchange_rate: exchangeRate, vat_treatment: vatTreatment, subtotal: Math.round(subtotal * 100) / 100, vat_amount: Math.round(vatAmount * 100) / 100, total: Math.round(total * 100) / 100, notes: (args.notes as string | undefined) ?? null, items: lineItems, ...(resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0 ? { default_dimensions: resolvedDefaultDimensions } : {}), } const previewData = { inbox_item_id: inboxItemId, supplier_id: supplierId, supplier_resolution: supplierResolution, extracted_supplier_name: supplierExt?.name ?? null, extracted_org_number: supplierExt?.organizationNumber ?? null, supplier_invoice_number: supplierInvoiceNumber, invoice_date: invoiceDate, due_date: dueDate, currency, exchange_rate: exchangeRate, exchange_rate_source: exchangeRate !== null ? 'riksbanken' : currency === 'SEK' ? 'not_applicable' : 'lookup_failed', vat_treatment: vatTreatment, subtotal: params.subtotal, vat_amount: params.vat_amount, total: params.total, line_count: lineItems.length, items_preview: lineItems.slice(0, 5), // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), will: 'register supplier invoice (status=registered), attach the inbox document, post a registration journal entry on confirm: leverantörsskuld (2440) credited and the cost/VAT split debited per the per-line VAT rules', } return stagePendingOperation( supabase, companyId, userId, 'create_supplier_invoice_from_inbox', `Leverantörsfaktura: ${supplierInvoiceNumber} (${(supplierExt?.name as string) ?? 'okänd'})`, params, previewData, actor, { description: 'After approval, attest via gnubok_approve_supplier_invoice and pay via the bank flow.', tool: 'gnubok_get_inbox_item', args: { inbox_item_id: inboxItemId }, }, { dryRun, idempotencyKey }, ) }, }, { name: 'gnubok_list_unmatched_documents', title: 'List Unmatched Documents', description: 'List inbox documents not yet attached to any bank transaction, supplier invoice, or journal entry. Returns vendor/amount/currency/date hints. Amount is in the invoice currency; FX-normalise before comparing to transactions.amount.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results (default 20, max 50)' }, cursor: { type: 'string', description: 'Composite "__" from previous page (exclusive). Pass next_cursor verbatim.' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { items: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, next_cursor: { type: 'string', description: 'Pass as cursor on next call. Absent = no more pages.' }, }, required: ['items', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) const cursor = typeof args.cursor === 'string' ? args.cursor : null // Composite cursor: "__". Falls back to plain timestamp // for backward compat with older callers. let cursorTs: string | null = null let cursorId: string | null = null if (cursor) { const sep = cursor.indexOf('__') if (sep === -1) { cursorTs = cursor } else { cursorTs = cursor.slice(0, sep) cursorId = cursor.slice(sep + 2) } } // Pull recent inbox items with a document, no supplier invoice and no // direct journal entry yet (both are terminal links per the same // "processed" semantics gnubok_list_inbox_items uses), then filter out // those whose document is already pinned to a transaction. // Two-step query because PostgREST doesn't expose anti-joins. const fetchSize = limit * 2 let inboxQuery = supabase .from('invoice_inbox_items') .select('id, document_id, source, email_from, email_subject, email_received_at, extracted_data, created_at') .eq('company_id', companyId) .not('document_id', 'is', null) .is('created_supplier_invoice_id', null) .is('created_journal_entry_id', null) .order('created_at', { ascending: false }) .order('id', { ascending: false }) .limit(fetchSize) if (cursorTs && cursorId) { // (created_at, id) < (cursorTs, cursorId): keyset pagination inboxQuery = inboxQuery.or( `created_at.lt.${cursorTs},and(created_at.eq.${cursorTs},id.lt.${cursorId})` ) } else if (cursorTs) { inboxQuery = inboxQuery.lt('created_at', cursorTs) } const { data: inboxRows, error: inboxError } = await inboxQuery if (inboxError) throw new Error(`Database error: ${inboxError.message}`) if (!inboxRows || inboxRows.length === 0) { return { items: [], count: 0 } } const docIds = inboxRows.map((r) => r.document_id).filter((d): d is string => d != null) const { data: txMatches, error: txError } = await supabase .from('transactions') .select('document_id') .eq('company_id', companyId) .in('document_id', docIds) if (txError) throw new Error(`Database error: ${txError.message}`) const matchedDocIds = new Set((txMatches || []).map((t) => t.document_id)) const unmatched = inboxRows .filter((r) => r.document_id && !matchedDocIds.has(r.document_id)) .slice(0, limit) .map((item) => { const extracted = item.extracted_data as Record | null let vendorName: string | null = null let orgNumber: string | null = null let amount: number | null = null let currency: string | null = null let invoiceDate: string | null = null let paymentReference: string | null = null if (extracted) { const supplier = extracted.supplier as Record | undefined const invoice = extracted.invoice as Record | undefined const totals = extracted.totals as Record | undefined vendorName = (supplier?.name as string) || null orgNumber = (supplier?.orgNumber as string) || null amount = (totals?.total as number) || null // Surface currency alongside amount so the agent doesn't compare a // non-SEK invoice numerically to a SEK transaction. transactions.amount // is in transactions.currency; if these don't match, the agent must // FX-normalise before ranking matches. Defaulting to null when absent // (rather than 'SEK') makes the missing-currency case explicit. currency = (invoice?.currency as string) || null invoiceDate = (invoice?.invoiceDate as string) || null paymentReference = (invoice?.paymentReference as string) || null } return { inbox_item_id: item.id, document_id: item.document_id, source: item.source, created_at: item.created_at, email_from: item.email_from, email_subject: item.email_subject, email_received_at: item.email_received_at, vendor_name: vendorName, org_number: orgNumber, amount, currency, invoice_date: invoiceDate, payment_reference: paymentReference, } }) // Pagination contract: emit next_cursor whenever the caller might be // missing rows. Two cases: // (a) slice was full → cursor on last returned item (next page picks up // any leftover unmatched rows we filtered past); // (b) slice was short but inbox query returned a full batch → cursor on // last inspected row (more unmatched may exist deeper in the inbox). // Only suppress the cursor when we exhausted the inbox stream entirely. let nextCursor: string | null = null if (unmatched.length === limit) { const last = unmatched[unmatched.length - 1] nextCursor = `${last.created_at}__${last.inbox_item_id}` } else if (inboxRows.length === fetchSize) { const last = inboxRows[inboxRows.length - 1] nextCursor = `${last.created_at}__${last.id}` } return { items: unmatched, count: unmatched.length, ...(nextCursor ? { next_cursor: nextCursor } : {}), } }, }, { name: 'gnubok_get_document_content', title: 'Get Document Content', description: 'Get a 5-minute signed download URL for a document so the agent can read its contents (e.g. with vision). Use after gnubok_list_unmatched_documents to inspect a specific PDF before deciding which transaction it matches.', inputSchema: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string', description: 'UUID of the document_attachments row' }, }, required: ['document_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string' }, file_name: { type: 'string' }, mime_type: { type: 'string' }, size_bytes: { type: 'number' }, signed_url: { type: 'string' }, expires_at: { type: 'string' }, }, required: ['document_id', 'file_name', 'signed_url', 'expires_at'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const documentId = args.document_id as string if (!documentId) throw new Error('document_id is required') const { data: doc, error: docError } = await supabase .from('document_attachments') .select('id, file_name, mime_type, file_size_bytes, storage_path') .eq('id', documentId) .eq('company_id', companyId) .maybeSingle() if (docError) throw new Error(`Database error: ${docError.message}`) if (!doc) throw new Error('Document not found') const ttlSeconds = 300 const { data: signed, error: signError } = await supabase.storage .from('documents') .createSignedUrl(doc.storage_path, ttlSeconds) if (signError || !signed) { throw new Error(`Failed to create signed URL: ${signError?.message ?? 'unknown error'}`) } const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString() return { document_id: doc.id, file_name: doc.file_name, mime_type: doc.mime_type, size_bytes: doc.file_size_bytes, signed_url: signed.signedUrl, expires_at: expiresAt, } }, }, { name: 'gnubok_attach_document_to_transaction', title: 'Attach Document to Transaction', description: 'Stage attaching a document to a bank transaction. Verify tx (date, amount, counterparty) and document (filename, vendor, amount) match first: the reviewer\'s preview mirrors what you pass here. Stages for approval.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string', description: 'UUID of the bank transaction' }, document_id: { type: 'string', description: 'UUID of the document_attachments row' }, idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' }, dry_run: { type: 'boolean', description: 'Preview without staging' }, }, required: ['transaction_id', 'document_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string const documentId = args.document_id as string if (!transactionId) throw new Error('transaction_id is required') if (!documentId) throw new Error('document_id is required') const { data: tx, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, date, document_id') .eq('id', transactionId) .eq('company_id', companyId) .maybeSingle() if (txError || !tx) throw new Error('Transaction not found') const { data: doc, error: docError } = await supabase .from('document_attachments') .select('id, file_name, mime_type') .eq('id', documentId) .eq('company_id', companyId) .maybeSingle() if (docError || !doc) throw new Error('Document not found') // If the tx already has a different doc pinned, fetch its identity so the // human approver sees "replaces X.pdf with Y.pdf" rather than just a flag. // Required by BFL 5 kap 5 § rättelse (the approver must know what's being // displaced before authorising the change). type ExistingDoc = { id: string; file_name: string; journal_entry_id: string | null } let existingDoc: ExistingDoc | null = null if (tx.document_id && tx.document_id !== documentId) { const { data: prev } = await supabase .from('document_attachments') .select('id, file_name, journal_entry_id') .eq('id', tx.document_id) .eq('company_id', companyId) .maybeSingle() if (prev) { existingDoc = prev as unknown as ExistingDoc } } // Pull the matching invoice_inbox_items extracted_data so the approver // sees vendor/amount/currency/date: the same hints the agent had when // choosing this attachment. Mirrors the BFL 5 kap 6 § informed-rättelse // intent: the human authorising the link should see what's on the doc. let docVendorName: string | null = null let docAmount: number | null = null let docCurrency: string | null = null let docInvoiceDate: string | null = null const { data: inbox } = await supabase .from('invoice_inbox_items') .select('extracted_data') .eq('document_id', documentId) .eq('company_id', companyId) .limit(1) .maybeSingle() if (inbox?.extracted_data) { const ext = inbox.extracted_data as Record const supplier = ext.supplier as Record | undefined const invoice = ext.invoice as Record | undefined const totals = ext.totals as Record | undefined docVendorName = (supplier?.name as string) || null docAmount = (totals?.total as number) || null docCurrency = (invoice?.currency as string) || null docInvoiceDate = (invoice?.invoiceDate as string) || null } return stagePendingOperation( supabase, companyId, userId, 'attach_document_to_transaction', `Koppla bilaga: ${doc.file_name} → ${tx.merchant_name || tx.description || transactionId}`, { transaction_id: transactionId, document_id: documentId }, { transaction_description: tx.merchant_name || tx.description, transaction_amount: tx.amount, transaction_currency: tx.currency, transaction_date: tx.date, document_file_name: doc.file_name, document_mime_type: doc.mime_type, document_vendor_name: docVendorName, document_amount: docAmount, document_currency: docCurrency, document_invoice_date: docInvoiceDate, will_overwrite_existing: existingDoc != null, existing_document_id: existingDoc?.id ?? null, existing_document_file_name: existingDoc?.file_name ?? null, existing_document_is_rakenskapsinformation: existingDoc?.journal_entry_id != null, }, actor, { description: 'Once approved, the receipt is linked to the transaction. If the transaction is still uncategorized, follow up with gnubok_categorize_transaction.', tool: 'gnubok_categorize_transaction', args: { transaction_id: transactionId }, }, { idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, dryRun: args.dry_run === true, // Pin the period-status envelope to the transaction date so the // approver sees locked/closed periods on the same row that // categorize_transaction surfaces them: the attach silently // becomes part of the verifikation underlag once categorize // propagates it (BFL 5 kap 6 § rättelse-räkenskapsinformation). dateForPeriodCheck: typeof tx.date === 'string' ? tx.date : undefined, } ) }, }, { name: 'gnubok_link_document_to_voucher', title: 'Link Document to Voucher', description: 'Stage linking a document to a posted verifikation. Use for imported/manual vouchers with no bank-tx row. Call gnubok_list_verifikat_without_documents first to find targets. Stages for approval.', inputSchema: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string', description: 'UUID of the document_attachments row' }, journal_entry_id: { type: 'string', description: 'UUID of the target journal entry (verifikation)' }, journal_entry_line_id: { type: 'string', description: 'Optional UUID to pin the doc to a specific debit/credit line' }, idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' }, dry_run: { type: 'boolean', description: 'Preview without staging' }, }, required: ['document_id', 'journal_entry_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const documentId = args.document_id as string const journalEntryId = args.journal_entry_id as string const journalEntryLineId = typeof args.journal_entry_line_id === 'string' ? args.journal_entry_line_id : undefined if (!documentId) throw new Error('document_id is required') if (!journalEntryId) throw new Error('journal_entry_id is required') const [docRes, jeRes] = await Promise.all([ supabase .from('document_attachments') .select('id, file_name, mime_type, journal_entry_id') .eq('id', documentId) .eq('company_id', companyId) .maybeSingle(), supabase .from('journal_entries') .select('id, entry_date, description, voucher_series, voucher_number, status') .eq('id', journalEntryId) .eq('company_id', companyId) .maybeSingle(), ]) if (docRes.error || !docRes.data) throw new Error('Document not found') if (jeRes.error || !jeRes.data) throw new Error('Journal entry not found') const doc = docRes.data as { id: string; file_name: string; mime_type: string; journal_entry_id: string | null } const je = jeRes.data as { id: string; entry_date: string; description: string voucher_series: string | null; voucher_number: number | null; status: string } const voucherLabel = je.voucher_series && je.voucher_number ? `${je.voucher_series}${je.voucher_number}` : je.id.slice(0, 8) const currentlyLinkedToSameJe = doc.journal_entry_id === journalEntryId const currentlyLinkedToOther = !!doc.journal_entry_id && !currentlyLinkedToSameJe return stagePendingOperation( supabase, companyId, userId, 'link_document_to_voucher', `Koppla bilaga: ${doc.file_name} → verifikat ${voucherLabel}`, { document_id: documentId, journal_entry_id: journalEntryId, journal_entry_line_id: journalEntryLineId ?? null }, { document_file_name: doc.file_name, document_mime_type: doc.mime_type, document_already_linked: currentlyLinkedToSameJe, document_currently_linked_to_other: currentlyLinkedToOther, document_current_journal_entry_id: doc.journal_entry_id ?? null, voucher_label: voucherLabel, voucher_date: je.entry_date, voucher_description: je.description, voucher_status: je.status, journal_entry_line_id: journalEntryLineId ?? null, }, actor, undefined, { idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, dryRun: args.dry_run === true, dateForPeriodCheck: je.entry_date, } ) }, }, // ── Payroll (Lönehantering) ────────────────────────────────── { name: 'gnubok_list_employees', title: 'List Employees', description: 'List employees for the active company. Personnummer is returned masked as personnummer_masked (YYYYMMDD-XXXX).', inputSchema: { type: 'object', additionalProperties: false, properties: { active_only: { type: 'boolean', description: 'Only active employees (default: true)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { employees: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['employees', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const activeOnly = args.active_only !== false let query = supabase .from('employees') // personnummer_last4 is deliberately NOT selected: the mask is // YYYYMMDD-XXXX, so mask + last4 in one payload reconstructs the full // personnummer by concatenation (see maskEmployeeForResponse). .select('id, first_name, last_name, personnummer, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, default_dimensions, is_active') .eq('company_id', companyId) if (activeOnly) query = query.eq('is_active', true) const { data, error } = await query.order('last_name') if (error) throw new Error(`Database error: ${error.message}`) // Shared masking helper: strips personnummer (ciphertext) AND // personnummer_last4, exposing only personnummer_masked: same shape as // the app routes and the other MCP payroll tools. const employees = (data || []).map(e => maskEmployeeForResponse(e as Record)) return { employees, count: employees.length } }, }, { name: 'gnubok_get_salary_run', title: 'Get Salary Run', description: 'Get salary run with status, totals, per-employee breakdown (gross, tax, net, avgifter, vacation accrual) and step-by-step calculation breakdown.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const id = args.salary_run_id as string const { data: run, error } = await supabase .from('salary_runs') .select('*') .eq('id', id) .eq('company_id', companyId) .single() if (error || !run) throw new Error('Salary run not found') const { data: employees } = await supabase .from('salary_run_employees') // The embed deliberately excludes personnummer_last4: the mask plus // last4 reconstructs the full personnummer (see maskEmployeeForResponse). .select('*, employee:employees(first_name, last_name, personnummer)') .eq('salary_run_id', id) return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? maskEmployeeForResponse(e.employee as Record) : null })) } }, }, { name: 'gnubok_get_salary_journal', title: 'Salary Journal (Lönejournal)', description: 'Salary journal (lönejournal) for a year: per-employee per-month rows + yearly totals.', inputSchema: { type: 'object', additionalProperties: false, properties: { year: { type: 'number', description: 'Year to report on' }, }, required: ['year'], }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const { generateSalaryJournal } = await import('@/lib/reports/salary-journal') return generateSalaryJournal(supabase, companyId, args.year as number) }, }, { name: 'gnubok_create_salary_run', title: 'Create Salary Run', description: 'Stage creation of a draft salary run for a period + base lines for all active employees. Commit via gnubok_approve_pending_operation; then run gnubok_calculate_salary_run and book via gnubok_book_salary_run.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_year: { type: 'number', description: 'Year' }, period_month: { type: 'number', description: 'Month (1-12)' }, payment_date: { type: 'string', description: 'Payment date (YYYY-MM-DD)' }, }, required: ['period_year', 'period_month', 'payment_date'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const { period_year, period_month, payment_date } = args as { period_year: number; period_month: number; payment_date: string } if (!Number.isInteger(period_year) || period_year < 1900 || period_year > 9999) { throw new Error('period_year must be a 4-digit year') } if (!Number.isInteger(period_month) || period_month < 1 || period_month > 12) { throw new Error('period_month must be 1-12') } if (typeof payment_date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(payment_date)) { throw new Error('payment_date must be YYYY-MM-DD') } // Preview: count active employees and surface base monthly salaries so // the approver knows what would be seeded. No writes here: the commit // path re-runs createSalaryRunWithEmployees atomically. const { count: employeeCount } = await supabase .from('employees') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .eq('is_active', true) const period = `${period_year}-${String(period_month).padStart(2, '0')}` return stagePendingOperation( supabase, companyId, userId, 'create_salary_run', `Skapa löneutbetalning: ${period} (${employeeCount ?? 0} anställda)`, { period_year, period_month, payment_date }, { period, payment_date, employee_count: employeeCount ?? 0, }, actor, { description: 'After approval, calculate tax, avgifter and totals.', tool: 'gnubok_calculate_salary_run', }, { dateForPeriodCheck: payment_date }, ) }, }, { name: 'gnubok_calculate_salary_run', title: 'Calculate Salary Run', description: 'Calculate a draft salary run: tax, avgifter, vacation accrual, totals. Run must be in draft status.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const id = args.salary_run_id as string if (!id) throw new Error('salary_run_id is required') // Call the extracted calculation lib directly: no self-fetch / forged // cookie, no NEXT_PUBLIC_APP_URL dependency. The lib enforces draft status // and owner-by-company itself. const { runSalaryCalculation } = await import('@/lib/salary/run-calculation') const { createLogger } = await import('@/lib/logger') const { randomUUID } = await import('node:crypto') const result = await runSalaryCalculation({ supabase, companyId, salaryRunId: id, log: createLogger('mcp/calculate_salary_run'), requestId: randomUUID(), }) if (!result.ok) { throw new Error(`Salary calculation failed: ${result.code}`) } return { salary_run_id: id, status: (result.run as { status?: string }).status ?? 'draft', warnings: result.warnings, message: 'Calculation complete. Review the run, then book it via gnubok_book_salary_run (or in the web UI).', next: { description: 'Review the calculated run; then stage booking via gnubok_book_salary_run.', tool: 'gnubok_get_salary_run', args: { salary_run_id: id }, }, } }, }, { name: 'gnubok_book_salary_run', title: 'Book Salary Run', description: 'Stage booking of a calculated salary run: advances godkänd/utbetald and posts the immutable lön verifikat. High-risk (BFL 5 kap). Commit via gnubok_approve_pending_operation (confirmed=true); then gnubok_generate_agi.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.salary_run_id as string if (!id) throw new Error('salary_run_id is required') const { data: run, error } = await supabase .from('salary_runs') .select('id, status, period_year, period_month, payment_date, total_gross, total_tax, total_net, total_avgifter') .eq('id', id) .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!run) throw new Error('Salary run not found') if (run.status === 'booked') throw new Error('Salary run is already booked') if (!['draft', 'review', 'approved', 'paid'].includes(run.status as string)) { throw new Error(`Salary run cannot be booked from status "${run.status}"`) } // Preflight: every roster row must be calculated, so the approver never // authorises a booking that the executor would reject. const { data: roster, error: rosterError } = await supabase .from('salary_run_employees') .select('id, calculation_breakdown') .eq('salary_run_id', id) if (rosterError) throw new Error(`Database error: ${rosterError.message}`) const rosterRows = roster ?? [] const uncalculated = rosterRows.filter((r) => !r.calculation_breakdown).length if (uncalculated > 0) { throw new Error(`${uncalculated} employee(s) lack a calculation: run gnubok_calculate_salary_run first`) } const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` return stagePendingOperation( supabase, companyId, userId, 'book_salary_run', `Bokför lönekörning ${period}: ${rosterRows.length} anställda, netto ${run.total_net ?? 0} kr`, { salary_run_id: id }, { salary_run_id: id, period, current_status: run.status, payment_date: run.payment_date, employee_count: rosterRows.length, total_gross: run.total_gross, total_tax: run.total_tax, total_avgifter: run.total_avgifter, total_net: run.total_net, }, actor, { description: 'After booking, generate the arbetsgivardeklaration for the period.', tool: 'gnubok_generate_agi', args: { salary_run_id: id }, }, { dateForPeriodCheck: run.payment_date as string }, ) }, }, { name: 'gnubok_generate_agi', title: 'Generate AGI Declaration', description: 'Stage AGI XML generation (Arbetsgivardeklaration) for a salary run. High-risk: produces statutory Skatteverket underlag (BFL 7-year retention). Commit via gnubok_approve_pending_operation.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.salary_run_id as string if (!id) throw new Error('salary_run_id is required') const { data: run } = await supabase .from('salary_runs') .select('id, status, period_year, period_month, payment_date') .eq('id', id) .eq('company_id', companyId) .maybeSingle() if (!run) throw new Error('Salary run not found') if (run.status === 'draft') { throw new Error('Salary run must be past draft before AGI can be generated') } const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` return stagePendingOperation( supabase, companyId, userId, 'generate_agi', `Generera AGI: ${period}`, { salary_run_id: id }, { period, status: run.status, payment_date: run.payment_date, retention_years: 7, }, actor, undefined, run.payment_date ? { dateForPeriodCheck: run.payment_date } : {}, ) }, }, // ── PR5: Skatteverket filing (external system, openWorldHint) ────── // // VAT (momsdeklaration) + AGI (arbetsgivardeklaration) filing from Claude. // Reads hit SKV live (and write BFL audit rows); the two submit tools stage // high-risk ops whose commit "sends for BankID signing": the user's // signature in the browser is the irreversible filing act, not the commit. { name: 'gnubok_vat_declaration_validate', title: 'Validate VAT Declaration (Momsdeklaration)', description: 'Pre-flight the period momsdeklaration: Skatteverket /kontrollera (read-only, saves nothing) PLUS the local completeness checks. Read arithmetic_ok and completeness_ok separately: Skatteverket only checks that the payload adds up, never that the underlag is complete.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' }, year: { type: 'number', description: 'Year (e.g. 2026)' }, period: { type: 'number', description: '1-12 for monthly, 1-4 for quarterly, 1 for yearly' }, }, required: ['period_type', 'year', 'period'], }, outputSchema: SKV_VAT_VALIDATE_OUTPUT_SCHEMA, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async execute(args, companyId, userId, supabase) { assertSkatteverketEnabled() const periodType = args.period_type as VatPeriodType const year = args.year as number const period = args.period as number const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket') // LOCAL pre-flight first, and deliberately outside the SKV try/catch so a // ledger read failure surfaces as itself rather than as a Skatteverket // error. Proxying /kontrollera alone is not a completeness check: SKV // confirms the payload is internally arithmetically consistent and // nothing more (a declaration of all zeros validates fine), so an agent // that treated a green kontrollresultat as "safe to file" could submit a // momsdeklaration missing its beskattningsunderlag (FK004). Same checks, // same gate helper, same verdict as the web filing UI. const declaration = await calculateVatDeclaration( supabase, companyId, periodType, year, period, ) // The 2645/2647 pair the declaration carries goes with it, so the RC input // comparison here is the sharp one too: ruta 48 alone would let ordinary // debiterad ingående moms hide a completely missing beräknad ingående moms. const completenessChecks = await runVatCompletenessChecks( supabase, companyId, declaration.rutor, periodType, year, period, rcInputTotalsFromDeclaration(declaration), ) const completenessOk = !isFilingBlocked(completenessChecks) try { const { redovisare, redovisningsperiod, momsuppgift } = await buildMomsuppgift(supabase, companyId, { periodType, year, period }) const res = await skvRequest( supabase, userId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift, ) await writeSkatteverketAudit(ctx, { endpoint: 'kontrollera', agRegistreradId: redovisare, redovisningsperiod, outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status, }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`Skatteverket svarade med ${res.status}: ${text}`) } const kontrollresultat = await res.json() const skvErrors = countSkvKontrollErrors(kontrollresultat) const arithmeticOk = skvErrors === 0 const errorCount = completenessChecks.filter((c) => c.status === 'ERROR').length const warningCount = completenessChecks.length - errorCount return { redovisare, redovisningsperiod, momsuppgift, kontrollresultat, arithmetic_ok: arithmeticOk, completeness_ok: completenessOk, completeness_checks: toCompletenessFindings(completenessChecks), summary: [ arithmeticOk ? 'Skatteverket: räknar ihop utan fel (kontrollerar bara summorna, inte underlaget).' : `Skatteverket: ${skvErrors} fel i deklarationen.`, completenessOk ? warningCount > 0 ? `Vår kontroll av underlaget: inga fel, ${warningCount} varning(ar) att granska.` : 'Vår kontroll av underlaget: inga fel.' : `Vår kontroll av underlaget: ${errorCount} fel, deklarationen är ofullständig och bör inte lämnas in.`, ].join(' '), } } catch (err) { throw mapSkatteverketError(err) } }, }, { name: 'gnubok_vat_declaration_submit', title: 'Submit VAT Declaration (Momsdeklaration)', description: 'Stage the period momsdeklaration for filing with Skatteverket. High-risk: approval sends it for BankID signing (returns a signing link); it is not filed until you sign. Always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' }, year: { type: 'number', description: 'Year (e.g. 2026)' }, period: { type: 'number', description: '1-12 for monthly, 1-4 for quarterly, 1 for yearly' }, }, required: ['period_type', 'year', 'period'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async execute(args, companyId, userId, supabase, actor) { assertSkatteverketEnabled() const periodType = args.period_type as VatPeriodType const year = args.year as number const period = args.period as number const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket') // Mandatory stage-time validation: the preview carries the real // kontrollresultat and we never stage a declaration SKV would reject. // /kontrollera is read-only on SKV's side. Shares buildMomsuppgift with // the commit executor so preview numbers == filed numbers. const prepared = await (async () => { try { const prep = await buildMomsuppgift(supabase, companyId, { periodType, year, period }) const res = await skvRequest( supabase, userId, 'POST', `/kontrollera/${prep.redovisare}/${prep.redovisningsperiod}`, prep.momsuppgift, ) await writeSkatteverketAudit(ctx, { endpoint: 'kontrollera', agRegistreradId: prep.redovisare, redovisningsperiod: prep.redovisningsperiod, outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status, }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`Skatteverket svarade med ${res.status}: ${text}`) } return { ...prep, kontrollresultat: await res.json() } } catch (err) { throw mapSkatteverketError(err) } })() return stagePendingOperation( supabase, companyId, userId, 'submit_vat_declaration', `Lämna momsdeklaration: ${prepared.redovisningsperiod}`, { period_type: periodType, year, period }, { redovisningsperiod: prepared.redovisningsperiod, redovisare: prepared.redovisare, rutor: prepared.momsuppgift, kontrollresultat: prepared.kontrollresultat, commit_action: 'Skickar för BankID-signering; lämnas inte in förrän du signerat.', }, actor, { description: 'After approval, sign in Skatteverket via the returned BankID link, then poll gnubok_vat_declaration_status.', tool: 'gnubok_vat_declaration_status', args: { period_type: periodType, year, period }, }, { dateForPeriodCheck: skvPeriodToEndDate(prepared.redovisningsperiod) }, ) }, }, { name: 'gnubok_vat_declaration_status', title: 'VAT Declaration Status (Momsdeklaration)', description: 'Fetch the filing status of a momsdeklaration from Skatteverket: inlämnat (submitted) and/or beslutat (decided). Sections are null when nothing is on file yet.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' }, year: { type: 'number', description: 'Year (e.g. 2026)' }, period: { type: 'number', description: '1-12 for monthly, 1-4 for quarterly, 1 for yearly' }, state: { type: 'string', enum: ['submitted', 'decided', 'both'], description: "Which view to fetch. Default 'both'." }, }, required: ['period_type', 'year', 'period'], }, outputSchema: SKV_VAT_STATUS_OUTPUT_SCHEMA, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async execute(args, companyId, userId, supabase) { assertSkatteverketEnabled() const periodType = args.period_type as VatPeriodType const year = args.year as number const period = args.period as number const state = (args.state as string) ?? 'both' const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket') try { const redovisare = await resolveRedovisare(supabase, companyId) const redovisningsperiod = formatRedovisningsperiod(periodType, year, period) let submitted: unknown = null let decided: unknown = null if (state === 'submitted' || state === 'both') { const res = await skvRequest(supabase, userId, 'GET', `/inlamnat/${redovisare}/${redovisningsperiod}`) await writeSkatteverketAudit(ctx, { endpoint: 'inlamnat', agRegistreradId: redovisare, redovisningsperiod, outcome: res.ok || res.status === 404 ? 'ok' : 'skv_error', responseStatus: res.status, }) if (res.status !== 404) { if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`Skatteverket svarade med ${res.status}: ${text}`) } submitted = await res.json() } } if (state === 'decided' || state === 'both') { const res = await skvRequest(supabase, userId, 'GET', `/beslutat/${redovisare}/${redovisningsperiod}`) await writeSkatteverketAudit(ctx, { endpoint: 'beslutat', agRegistreradId: redovisare, redovisningsperiod, outcome: res.ok || res.status === 404 ? 'ok' : 'skv_error', responseStatus: res.status, }) if (res.status !== 404) { if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(`Skatteverket svarade med ${res.status}: ${text}`) } decided = await res.json() } } return { redovisare, redovisningsperiod, submitted, decided } } catch (err) { throw mapSkatteverketError(err) } }, }, { name: 'gnubok_agi_submit', title: 'Submit AGI Declaration (Arbetsgivardeklaration)', description: "Stage filing of a salary run's arbetsgivardeklaration (AGI) with Skatteverket. High-risk: approval posts the XML underlag and returns a BankID signing link; it is not filed until you sign. Always staged.", inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async execute(args, companyId, userId, supabase, actor) { assertSkatteverketEnabled() const salaryRunId = args.salary_run_id as string if (!salaryRunId) throw new Error('salary_run_id is required') // Local preconditions only: NO SKV call at stage time. The commit // executor posts the underlag + creates the granskningsunderlag on approval. const { data: run } = await supabase .from('salary_runs') .select('id, status, period_year, period_month, payment_date') .eq('id', salaryRunId) .eq('company_id', companyId) .maybeSingle() if (!run) throw new Error('Salary run not found') const { data: decl } = await supabase .from('agi_declarations') .select('id, status, xml_content') .eq('salary_run_id', salaryRunId) .eq('company_id', companyId) .order('created_at', { ascending: false }) .limit(1) .maybeSingle() if (!decl?.xml_content) { throw new Error('AGI-underlag saknas: generera AGI först med gnubok_generate_agi.') } const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` return stagePendingOperation( supabase, companyId, userId, 'submit_agi', `Lämna AGI: ${period}`, { salary_run_id: salaryRunId }, { period, salary_run_status: run.status, agi_declaration_id: decl.id, retention_years: 7, commit_action: 'Skickar underlag + returnerar BankID-signeringslänk; lämnas inte in förrän du signerat.', }, actor, { description: 'After approval, sign via the returned BankID link, then poll gnubok_agi_status.', tool: 'gnubok_agi_status', args: { salary_run_id: salaryRunId }, }, run.payment_date ? { dateForPeriodCheck: run.payment_date } : {}, ) }, }, { name: 'gnubok_agi_status', title: 'AGI Declaration Status (Arbetsgivardeklaration)', description: "Fetch AGI filing status for a salary run: run-scoped filing_state and kvittensnummer (a correction run never inherits the superseded original's receipt), plus live Skatteverket kvittenser.", inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: SKV_AGI_STATUS_OUTPUT_SCHEMA, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async execute(args, companyId, userId, supabase) { assertSkatteverketEnabled() const salaryRunId = args.salary_run_id as string if (!salaryRunId) throw new Error('salary_run_id is required') const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket') try { const { data: run } = await supabase .from('salary_runs') // agi_generated_at / agi_submitted_at feed the run-scoped filing // resolution below. .select('id, period_year, period_month, agi_generated_at, agi_submitted_at') .eq('id', salaryRunId) .eq('company_id', companyId) .maybeSingle() if (!run) throw new Error('Salary run not found') const arbetsgivare = await resolveRedovisare(supabase, companyId) const period = formatRedovisningsperiod('monthly', run.period_year, run.period_month) // Local cached submission state (extension_data key agi_submission_${period}). const { data: localRow } = await supabase .from('extension_data') .select('value') .eq('company_id', companyId) .eq('extension_id', 'skatteverket') .eq('key', `agi_submission_${period}`) .maybeSingle() let periodRecord: AgiSubmissionState | null = null if (localRow?.value) { try { periodRecord = JSON.parse(localRow.value as string) as AgiSubmissionState } catch { periodRecord = null } } // Run-scope the period-keyed record (lib/salary/agi-submission-state.ts, // same resolution AGIPanel and the run page use). salary_runs is unique // per period only for non-corrected runs (partial index, migration // 20260414130000), so a correction run coexists with the run it // corrects and the two share one agi_submission_{period} record. // Returning that record raw rendered a correction as already filed // with the ORIGINAL run's kvittens, hiding the filing action: a // correction is a complete resubmission for the same // redovisningsperiod that gets its own kvittens. const runForFiling = { id: run.id as string, agi_generated_at: (run as { agi_generated_at?: string | null }).agi_generated_at ?? null, agi_submitted_at: (run as { agi_submitted_at?: string | null }).agi_submitted_at ?? null, } const ownSubmission = resolveRunAgiSubmission(runForFiling, periodRecord) const filingState = deriveAgiFilingState(runForFiling, periodRecord) const kvittensnummer = resolveRunAgiKvittensnummer(runForFiling, periodRecord) // Live kvittenser (read-only). A non-ok read (e.g. nothing filed yet) // leaves kvittenser null rather than hard-failing the status check; // auth errors throw and map to SKATTEVERKET_NOT_CONNECTED. let kvittenser: unknown = null const res = await agiGetKvittenser({ mode: 'user', supabase, userId }, arbetsgivare, period) await writeSkatteverketAudit(ctx, { endpoint: 'kvittenser', agRegistreradId: arbetsgivare, redovisningsperiod: period, outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status, }) if (res.ok) kvittenser = res.data.kvittenser return { salary_run_id: salaryRunId, period, filing_state: filingState, kvittensnummer, // Run-scoped: null when the period record belongs to another run // (the agent must not read a sibling run's receipt as this one's). local_state: ownSubmission, kvittenser, } } catch (err) { throw mapSkatteverketError(err) } }, }, { name: 'gnubok_get_employee', title: 'Get Employee', description: 'Get one employee\'s full payroll config: salary, tax table/column, jamkning, F-skatt, vacation rule, vaxa-stod, bank details, dimensions. Personnummer masked. Use after gnubok_list_employees to drill into one employee before payroll work.', inputSchema: { type: 'object', additionalProperties: false, properties: { employee_id: { type: 'string', description: 'UUID of the employee' }, }, required: ['employee_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { employee_id: { type: 'string' }, first_name: { type: 'string' }, last_name: { type: 'string' }, personnummer_masked: { type: 'string' }, employment: { type: 'object', description: 'Type, start/end, degree' }, pay: { type: 'object', description: 'Salary type + amounts' }, tax: { type: 'object', description: 'Table, column, municipality, jamkning, F-skatt, sidoinkomst' }, vacation: { type: 'object', description: 'Rule, days per year, saved days, tillagg rate' }, vaxa_stod: { type: 'object', description: 'Eligibility window' }, bank: { type: 'object', description: 'Clearing + account (payment routing)' }, default_dimensions: { type: 'object' }, is_active: { type: 'boolean' }, }, required: ['employee_id', 'first_name', 'last_name', 'personnummer_masked', 'is_active'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const employeeId = args.employee_id as string if (!employeeId) throw new Error('employee_id is required') const { data: e, error } = await supabase .from('employees') .select( 'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, hours_per_week, workdays_per_week, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, f_skatt_verified_at, jamkning_percentage, jamkning_valid_from, jamkning_valid_to, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, vacation_days_saved, semestertillagg_rate, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active', ) .eq('id', employeeId) .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!e) throw new Error('Employee not found') // LLM context is a leak surface: personnummer is ALWAYS masked on MCP, // there is no full-value drill-in on this surface. return { employee_id: e.id, first_name: e.first_name, last_name: e.last_name, personnummer_masked: maskPersonnummer(decryptPersonnummer(e.personnummer as string)), employment: { employment_type: e.employment_type, employment_start: e.employment_start, employment_end: e.employment_end, employment_degree: e.employment_degree, hours_per_week: e.hours_per_week, workdays_per_week: e.workdays_per_week, }, pay: { salary_type: e.salary_type, monthly_salary: e.monthly_salary, hourly_rate: e.hourly_rate, }, tax: { tax_table_number: e.tax_table_number, tax_column: e.tax_column, tax_municipality: e.tax_municipality, is_sidoinkomst: e.is_sidoinkomst, f_skatt_status: e.f_skatt_status, f_skatt_verified_at: e.f_skatt_verified_at, jamkning_percentage: e.jamkning_percentage, jamkning_valid_from: e.jamkning_valid_from, jamkning_valid_to: e.jamkning_valid_to, }, vacation: { vacation_rule: e.vacation_rule, vacation_days_per_year: e.vacation_days_per_year, vacation_days_saved: e.vacation_days_saved, semestertillagg_rate: e.semestertillagg_rate, }, vaxa_stod: { eligible: e.vaxa_stod_eligible, start: e.vaxa_stod_start, end: e.vaxa_stod_end, }, bank: { clearing_number: e.clearing_number, bank_account_number: e.bank_account_number, }, default_dimensions: e.default_dimensions ?? {}, is_active: e.is_active, } }, }, { name: 'gnubok_get_payslip', title: 'Get Payslip (Lönebesked)', description: 'Get one employee\'s payslip in a salary run: gross, tax, avgifter, net, every line item and the step-by-step calculation breakdown. Personnummer masked. Use after gnubok_get_salary_run to verify how one employee\'s pay was computed.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, employee_id: { type: 'string', description: 'UUID of the employee' }, }, required: ['salary_run_id', 'employee_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_employee_id: { type: 'string' }, salary_run_id: { type: 'string' }, employee_id: { type: 'string' }, employee_name: { type: 'string' }, personnummer_masked: { type: 'string' }, amounts: { type: 'object', description: 'Gross, taxable, tax, net, avgifter, vacation accrual, YTD' }, overrides: { type: 'object', description: 'Manual tax/avgifter overrides + reason (effective = override ?? calculated)' }, absence_days: { type: 'object', description: 'Sick/vab/parental/vacation day counts' }, line_items: { type: 'array', items: { type: 'object' }, description: 'Each with salary_line_item_id' }, calculation_breakdown: { type: 'object', description: 'Step-by-step engine breakdown; null until calculated' }, }, required: ['salary_run_employee_id', 'salary_run_id', 'employee_id', 'employee_name', 'personnummer_masked', 'amounts', 'line_items'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const salaryRunId = args.salary_run_id as string const employeeId = args.employee_id as string if (!salaryRunId || !employeeId) throw new Error('salary_run_id and employee_id are required') const { data: sre, error } = await supabase .from('salary_run_employees') .select( '*, employee:employees(first_name, last_name, personnummer), line_items:salary_line_items(id, item_type, description, quantity, unit_price, amount, is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, account_number, sort_order)', ) .eq('salary_run_id', salaryRunId) .eq('employee_id', employeeId) .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!sre) throw new Error('Employee not found in this salary run') const emp = sre.employee as { first_name: string; last_name: string; personnummer: string } | null const lineItems = ((sre.line_items ?? []) as Array>) .slice() .sort((a, b) => ((a.sort_order as number) ?? 0) - ((b.sort_order as number) ?? 0)) .map(({ id, ...rest }) => ({ salary_line_item_id: id, ...rest })) return { salary_run_employee_id: sre.id, salary_run_id: salaryRunId, employee_id: employeeId, employee_name: emp ? `${emp.first_name} ${emp.last_name}` : '', personnummer_masked: emp ? maskPersonnummer(decryptPersonnummer(emp.personnummer)) : '', amounts: { gross_salary: sre.gross_salary, gross_deductions: sre.gross_deductions, benefit_values: sre.benefit_values, taxable_income: sre.taxable_income, tax_withheld: sre.tax_withheld, net_deductions: sre.net_deductions, net_salary: sre.net_salary, avgifter_rate: sre.avgifter_rate, avgifter_basis: sre.avgifter_basis, avgifter_amount: sre.avgifter_amount, avgifter_category: sre.avgifter_category, vacation_accrual: sre.vacation_accrual, vacation_accrual_avgifter: sre.vacation_accrual_avgifter, ytd_gross: sre.ytd_gross, ytd_tax: sre.ytd_tax, ytd_net: sre.ytd_net, }, overrides: { tax_withheld_override: sre.tax_withheld_override, avgifter_amount_override: sre.avgifter_amount_override, avgifter_basis_override: sre.avgifter_basis_override, override_reason: sre.override_reason, }, absence_days: { sick_days: sre.sick_days, vab_days: sre.vab_days, parental_days: sre.parental_days, vacation_days_taken: sre.vacation_days_taken, }, line_items: lineItems, calculation_breakdown: sre.calculation_breakdown ?? null, } }, }, { name: 'gnubok_list_absence', title: 'List Absence (Frånvaro)', description: 'List an employee\'s registered absence days (sick, vab, parental, ...) in a date range, max 92 days. These per-day rows drive karensavdrag and sjuklön at calculation time. Use before gnubok_register_absence to see what is already registered.', inputSchema: { type: 'object', additionalProperties: false, properties: { employee_id: { type: 'string', description: 'UUID of the employee' }, from: { type: 'string', description: 'Range start (YYYY-MM-DD, inclusive)' }, to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive, max 92 days)' }, absence_type: { type: 'string', enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'], description: 'Optional filter', }, }, required: ['employee_id', 'from', 'to'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { absence_days: { type: 'array', items: { type: 'object' }, description: 'Each with salary_absence_day_id' }, count: { type: 'number' }, }, required: ['absence_days', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const { employee_id, from, to, absence_type } = args as { employee_id: string; from: string; to: string; absence_type?: string } if (!employee_id || !from || !to) throw new Error('employee_id, from and to are required') const { listAbsenceDays } = await import('@/lib/salary/absence') const result = await listAbsenceDays(supabase, { companyId, employeeId: employee_id, from, to, absenceType: absence_type, }) if (!result.ok) throw new Error(result.code === 'EMPLOYEE_NOT_FOUND' ? 'Employee not found' : `Failed to list absence: ${result.code}`) const days = result.data.map(({ id, ...rest }) => ({ salary_absence_day_id: id, ...rest })) return { absence_days: days, count: days.length } }, }, { name: 'gnubok_update_payslip_line', title: 'Update Payslip Line', description: 'Stage an edit to one payslip line (amount, description, quantity, unit price) in a DRAFT salary run. Commit via gnubok_approve_pending_operation, then re-run gnubok_calculate_salary_run: line edits never recompute tax by themselves.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run (must be draft)' }, salary_line_item_id: { type: 'string', description: 'UUID of the payslip line to edit' }, amount: { type: 'number', description: 'New amount (SEK)' }, description: { type: 'string', description: 'New line description' }, quantity: { type: 'number', description: 'New quantity' }, unit_price: { type: 'number', description: 'New unit price (SEK)' }, }, required: ['salary_run_id', 'salary_line_item_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const { salary_run_id, salary_line_item_id, amount, description, quantity, unit_price } = args as { salary_run_id: string; salary_line_item_id: string amount?: number; description?: string; quantity?: number; unit_price?: number } if (!salary_run_id || !salary_line_item_id) { throw new Error('salary_run_id and salary_line_item_id are required') } const patch: Record = {} if (amount !== undefined) patch.amount = amount if (description !== undefined) patch.description = description if (quantity !== undefined) patch.quantity = quantity if (unit_price !== undefined) patch.unit_price = unit_price if (Object.keys(patch).length === 0) { throw new Error('At least one of amount, description, quantity, unit_price is required') } // Preflight via the shared service in dry-run: verifies draft status and // that the line belongs to this run, and yields the merged row for the // preview. No writes here: the commit path re-runs the service for real. const { updatePayslipLine } = await import('@/lib/salary/payslip-lines') const preflight = await updatePayslipLine(supabase, { companyId, salaryRunId: salary_run_id, lineId: salary_line_item_id, patch: patch as never, dryRun: true, }) if (!preflight.ok) { throw new Error(`Cannot update payslip line: ${preflight.code}`) } const merged = preflight.data const { data: run } = await supabase .from('salary_runs') .select('payment_date') .eq('id', salary_run_id) .eq('company_id', companyId) .maybeSingle() return stagePendingOperation( supabase, companyId, userId, 'update_payslip_line', `Uppdatera lönebeskedsrad: ${merged.description}`, { salary_run_id, salary_line_item_id, patch }, { salary_run_id, salary_line_item_id, item_type: merged.item_type, description: merged.description, new_amount: merged.amount, changes: patch, }, actor, { description: 'After approval, recalculate the run so tax and totals reflect the edit.', tool: 'gnubok_calculate_salary_run', }, run?.payment_date ? { dateForPeriodCheck: run.payment_date as string } : {}, ) }, }, { name: 'gnubok_register_absence', title: 'Register Absence (Frånvaro)', description: 'Stage absence registration (sick, vab, parental, ...) for an employee over a date range, max 92 days, weekends skipped unless included. Commit via gnubok_approve_pending_operation; recalculate any open salary run afterwards.', inputSchema: { type: 'object', additionalProperties: false, properties: { employee_id: { type: 'string', description: 'UUID of the employee' }, from: { type: 'string', description: 'Range start (YYYY-MM-DD, inclusive)' }, to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive; single day = same as from)' }, absence_type: { type: 'string', enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'], description: 'Absence type', }, hours_per_day: { type: 'number', description: 'Hours per day (default 8; use e.g. 4 for half days)' }, notes: { type: 'string', description: 'Optional note' }, include_weekends: { type: 'boolean', description: 'Also register Saturday/Sunday (default false)' }, }, required: ['employee_id', 'from', 'to', 'absence_type'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const { employee_id, from, to, absence_type, hours_per_day, notes, include_weekends } = args as { employee_id: string; from: string; to: string; absence_type: string hours_per_day?: number; notes?: string; include_weekends?: boolean } if (!employee_id || !from || !to || !absence_type) { throw new Error('employee_id, from, to and absence_type are required') } if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) { throw new Error('from and to must be YYYY-MM-DD') } // Preflight in dry-run: verifies the employee and expands the range so // the approver sees exactly which days would be written. const { upsertAbsenceRange } = await import('@/lib/salary/absence') const preflight = await upsertAbsenceRange(supabase, { companyId, employeeId: employee_id, from, to, absenceType: absence_type, hoursPerDay: hours_per_day, notes: notes ?? null, includeWeekends: include_weekends, dryRun: true, }) if (!preflight.ok) { throw new Error(`Cannot register absence: ${preflight.code}`) } const { data: emp } = await supabase .from('employees') .select('first_name, last_name') .eq('id', employee_id) .eq('company_id', companyId) .maybeSingle() const employeeName = emp ? `${emp.first_name} ${emp.last_name}` : employee_id return stagePendingOperation( supabase, companyId, userId, 'register_absence', `Registrera frånvaro: ${employeeName}, ${absence_type} ${from}${to !== from ? ` till ${to}` : ''}`, { employee_id, from, to, absence_type, hours_per_day: hours_per_day ?? 8, notes: notes ?? null, include_weekends: include_weekends ?? false }, { employee_id, employee_name: employeeName, absence_type, from, to, day_count: preflight.data.count, hours_per_day: hours_per_day ?? 8, dates_sample: preflight.data.days.slice(0, 10).map((d) => (d as { absence_date: string }).absence_date), }, actor, { description: 'If a draft salary run covers this period, recalculate it so sjuklön/karensavdrag lines update.', tool: 'gnubok_calculate_salary_run', }, { dateForPeriodCheck: from }, ) }, }, { name: 'gnubok_delete_absence', title: 'Delete Absence (Frånvaro)', description: 'Stage removal of registered absence days in a date range, optionally one type only. Inverse of gnubok_register_absence. Commit via gnubok_approve_pending_operation; recalculate any draft salary run afterwards.', inputSchema: { type: 'object', additionalProperties: false, properties: { employee_id: { type: 'string', description: 'UUID of the employee' }, from: { type: 'string', description: 'Range start (YYYY-MM-DD)' }, to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive)' }, absence_type: { type: 'string', enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'], description: 'Only this type (omit = all)', }, }, required: ['employee_id', 'from', 'to'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const { employee_id, from, to, absence_type } = args as { employee_id: string; from: string; to: string; absence_type?: string } if (!employee_id || !from || !to) { throw new Error('employee_id, from and to are required') } if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) { throw new Error('from and to must be YYYY-MM-DD') } // Preflight in dry-run: verifies the employee and counts the rows so // the approver sees exactly how many days would be removed. const { deleteAbsenceRange } = await import('@/lib/salary/absence') const preflight = await deleteAbsenceRange(supabase, { companyId, employeeId: employee_id, from, to, absenceType: absence_type, dryRun: true, }) if (!preflight.ok) { throw new Error(`Cannot delete absence: ${preflight.code}`) } if (preflight.data.deleted_count === 0) { throw new Error('No registered absence days in that range: nothing to delete') } const { data: emp } = await supabase .from('employees') .select('first_name, last_name') .eq('id', employee_id) .eq('company_id', companyId) .maybeSingle() const employeeName = emp ? `${emp.first_name} ${emp.last_name}` : employee_id return stagePendingOperation( supabase, companyId, userId, 'delete_absence', `Ta bort frånvaro: ${employeeName}, ${absence_type ?? 'alla typer'} ${from}${to !== from ? ` till ${to}` : ''}`, { employee_id, from, to, absence_type: absence_type ?? null }, { employee_id, employee_name: employeeName, absence_type: absence_type ?? null, from, to, day_count: preflight.data.deleted_count, }, actor, { description: 'If a draft salary run covers this period, recalculate it so sjuklön/karensavdrag lines update.', tool: 'gnubok_calculate_salary_run', }, { dateForPeriodCheck: from }, ) }, }, { name: 'gnubok_create_employee', title: 'Create Employee', description: 'Stage creation of a new employee: salary, tax table, bank details, vacation rule. Personnummer is encrypted at staging and never stored in plaintext. Commit via gnubok_approve_pending_operation; then attach to a salary run.', inputSchema: { type: 'object', additionalProperties: false, properties: { first_name: { type: 'string' }, last_name: { type: 'string' }, personnummer: { type: 'string', description: '12 digits (YYYYMMDDNNNN). Encrypted at staging.' }, employment_type: { type: 'string', enum: ['employee', 'company_owner', 'board_member'] }, employment_start: { type: 'string' }, employment_end: { type: 'string' }, employment_degree: { type: 'number', description: '1-100 (default 100)' }, hours_per_week: { type: 'number', description: 'Schedule hours/week (default 40; drives hourly divisor)' }, workdays_per_week: { type: 'number', description: 'Schedule days/week (default 5; drives daily divisor)' }, salary_type: { type: 'string', enum: ['monthly', 'hourly'] }, monthly_salary: { type: 'number' }, hourly_rate: { type: 'number' }, tax_table_number: { type: 'number', description: '29-42; required for A-skatt non-sidoinkomst' }, tax_column: { type: 'number' }, tax_municipality: { type: 'string' }, is_sidoinkomst: { type: 'boolean' }, f_skatt_status: { type: 'string', enum: ['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'] }, clearing_number: { type: 'string' }, bank_account_number: { type: 'string' }, vacation_rule: { type: 'string', enum: ['procentregeln', 'sammaloneregeln', 'semesterersattning', 'none'] }, vacation_days_per_year: { type: 'number' }, email: { type: 'string' }, phone: { type: 'string' }, vaxa_stod_eligible: { type: 'boolean' }, vaxa_stod_start: { type: 'string' }, vaxa_stod_end: { type: 'string' }, jamkning_percentage: { type: 'number' }, jamkning_valid_from: { type: 'string' }, jamkning_valid_to: { type: 'string' }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn} tagging this employee\'s salary cost lines on every run. Never auto-created.', }, }, required: ['first_name', 'last_name', 'personnummer', 'employment_start'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { // Resolve-don't-select BEFORE schema validation: the bag may carry // registry value NAMES, which the strict DimensionsBagSchema inside // CreateEmployeeSchema would reject. const { bags: employeeDimBags } = await resolveDimensionBags( supabase, companyId, [parseDimensionsArg(args.default_dimensions, 'default_dimensions')], ) if (args.default_dimensions !== undefined) { args = { ...args, default_dimensions: employeeDimBags[0] ?? {} } } const { CreateEmployeeSchema } = await import('@/lib/api/schemas') const parsed = CreateEmployeeSchema.safeParse(args) if (!parsed.success) { const first = parsed.error.issues[0] throw new Error(`Invalid employee: ${first ? `${first.path.join('.')}: ${first.message}` : 'validation failed'}`) } const body = parsed.data // Preflight the EF-owner rule so staging fails early with a clean error. const { getCompanyEntityType } = await import('@/lib/company/context') const { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } = await import('@/lib/salary/employment-rules') const entityType = await getCompanyEntityType(supabase, companyId) if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) { throw new Error(EF_OWNER_EMPLOYMENT_ERROR) } // PII rule: encrypt AT STAGING TIME. pending_operations.params never // holds the plaintext personnummer; previews and titles carry the // masked form only. const { encryptPersonnummer, extractLast4 } = await import('@/lib/salary/personnummer') const { personnummer, ...fields } = body const params: Record = { ...fields, personnummer_encrypted: encryptPersonnummer(personnummer), personnummer_last4: extractLast4(personnummer), } const masked = maskPersonnummer(personnummer) return stagePendingOperation( supabase, companyId, userId, 'create_employee', `Skapa anställd: ${body.first_name} ${body.last_name}`, params, { first_name: body.first_name, last_name: body.last_name, personnummer_masked: masked, employment_type: body.employment_type, employment_start: body.employment_start, salary_type: body.salary_type, monthly_salary: body.monthly_salary ?? null, hourly_rate: body.hourly_rate ?? null, tax_table_number: body.tax_table_number ?? null, bank_details_provided: !!(body.clearing_number && body.bank_account_number), }, actor, { description: 'After approval, attach the employee to a salary run.', tool: 'gnubok_create_salary_run', }, ) }, }, { name: 'gnubok_update_employee', title: 'Update Employee', description: 'Stage an update to an employee\'s payroll config: salary, tax, bank details, vacation rule, jamkning, vaxa-stod. Personnummer cannot be changed. Call gnubok_get_employee first to see current values; commit via gnubok_approve_pending_operation.', inputSchema: { type: 'object', additionalProperties: false, properties: { employee_id: { type: 'string', description: 'UUID of the employee' }, first_name: { type: 'string' }, last_name: { type: 'string' }, employment_type: { type: 'string', enum: ['employee', 'company_owner', 'board_member'] }, employment_start: { type: 'string' }, employment_end: { type: 'string' }, employment_degree: { type: 'number' }, hours_per_week: { type: 'number' }, workdays_per_week: { type: 'number' }, salary_type: { type: 'string', enum: ['monthly', 'hourly'] }, monthly_salary: { type: 'number' }, hourly_rate: { type: 'number' }, tax_table_number: { type: 'number' }, tax_column: { type: 'number' }, tax_municipality: { type: 'string' }, is_sidoinkomst: { type: 'boolean' }, f_skatt_status: { type: 'string', enum: ['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'] }, clearing_number: { type: 'string' }, bank_account_number: { type: 'string' }, vacation_rule: { type: 'string', enum: ['procentregeln', 'sammaloneregeln', 'semesterersattning', 'none'] }, vacation_days_per_year: { type: 'number' }, email: { type: 'string' }, phone: { type: 'string' }, is_active: { type: 'boolean', description: 'false soft-deactivates (BFL retention keeps the row)' }, vaxa_stod_eligible: { type: 'boolean' }, vaxa_stod_start: { type: 'string' }, vaxa_stod_end: { type: 'string' }, jamkning_percentage: { type: ['number', 'null'], description: 'null clears the beslut' }, jamkning_valid_from: { type: ['string', 'null'] }, jamkning_valid_to: { type: ['string', 'null'] }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn} tagging salary cost lines. Replaces the whole bag; {} clears all tags. Omit to keep.', }, }, required: ['employee_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const { employee_id, ...rest } = args as { employee_id: string } & Record if (!employee_id) throw new Error('employee_id is required') if ('personnummer' in rest) { throw new Error('personnummer cannot be changed: identity is immutable post-create') } // Resolve-don't-select: names resolve to registry codes; an explicit {} // stays {} (the clear-all-tags update). if (rest.default_dimensions !== undefined) { const { bags: employeeDimBags } = await resolveDimensionBags( supabase, companyId, [parseDimensionsArg(rest.default_dimensions, 'default_dimensions')], ) rest.default_dimensions = employeeDimBags[0] ?? {} } const patch: Record = {} for (const [key, value] of Object.entries(rest)) { if (value !== undefined) patch[key] = value } if (Object.keys(patch).length === 0) { throw new Error('At least one field to update is required') } const { data: existing, error } = await supabase .from('employees') .select('*') .eq('id', employee_id) .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!existing) throw new Error('Employee not found') const changes = Object.entries(patch).map(([field, to]) => ({ field, from: (existing as Record)[field] ?? null, to, })) const bankChanged = changes.some((c) => c.field === 'clearing_number' || c.field === 'bank_account_number') return stagePendingOperation( supabase, companyId, userId, 'update_employee', `Uppdatera anställd: ${existing.first_name} ${existing.last_name}`, { employee_id, patch }, { employee_id, employee_name: `${existing.first_name} ${existing.last_name}`, changes, // Bank routing changes are the BEC/fraud surface: surface them // prominently so the approver cannot miss a rerouted payment. bank_details_changed: bankChanged, }, actor, ) }, }, { name: 'gnubok_set_employee_opening_balances', title: 'Set Employee Opening Balances (Cutover)', description: 'Stage payroll cutover state per employee: YTD gross/tax/net, vacation days remaining and taken this year, sparade dagar by origin year, opening semesterlöneskuld SEK, karens adjustment. An omitted field keeps its stored value; send 0 to clear it. Locked after a booked run.', inputSchema: { type: 'object', additionalProperties: false, properties: { items: { type: 'array', minItems: 1, maxItems: 200, items: { type: 'object', additionalProperties: false, properties: { employee_id: { type: 'string', description: 'UUID of the employee' }, cutover_date: { type: 'string', description: 'First day of the first Accounted-run month (YYYY-MM-01)' }, ytd_gross: { type: 'number' }, ytd_tax: { type: 'number' }, ytd_net: { type: 'number' }, vacation_paid_days_remaining: { type: 'number' }, vacation_days_taken_this_year: { type: 'number', description: 'Paid days already taken this vacation year under the previous system (0-40)' }, vacation_saved_days_by_year: { type: 'object', description: 'Origin year -> days, e.g. {"2025": 5}; {} clears' }, opening_semester_liability: { type: 'number', description: 'SEK on 2920 (report-only; booked via SIE)' }, opening_semester_liability_avgifter: { type: 'number', description: 'SEK on 2940' }, karens_periods_adjustment: { type: 'number', description: 'Karens periods last 12 months not imported as absence rows (0-10)' }, }, required: ['employee_id', 'cutover_date'], }, }, }, required: ['items'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { // Sparse merge, NOT full replace. OpeningBalancesBulkSchema carries a // .default() on all nine non-key fields (and .partial() would not strip // them: Zod applies defaults through it), so parsing the caller's args // straight into the 10-column upsert resets ytd_tax, ytd_net, vacation // days, sparade dagar, the opening semesterlöneskuld and the karens // adjustment to 0 whenever an agent corrects a single figure. Same // defence as gnubok_update_employee: keep only the keys actually sent, // then layer them over the stored row. That is also what makes the // idempotentHint above true. // // Three invariants a future refactor must preserve: // 1. The schema validates the MERGED item, never the sparse patch. // openingBalancesRefine is CROSS-FIELD (ytd_tax <= ytd_gross, // sparade-dagar origin years vs cutover_date), so a patch parsed on // its own would see default 0s and either wave through tax > gross // or reject a legitimate single-field correction. That is why a // generic sparse-patch helper cannot be dropped in here: those // narrow the write set AFTER parsing the caller's body alone. // 2. The merge happens at STAGE time, so pending_operations.params // holds the complete post-merge row. The approver reads exactly // what will be written, and the executor re-parses the same items. // 3. vacation_saved_days_by_year is replaced WHOLESALE when sent: it // lands in one jsonb column, so a per-year merge would persist a // map the caller never described. Send the full map, or omit it. const rawItems = (args as { items?: unknown }).items if (!Array.isArray(rawItems) || rawItems.length === 0) { throw new Error('Invalid opening balances: items must be a non-empty array') } const MERGEABLE_FIELDS = [ 'ytd_gross', 'ytd_tax', 'ytd_net', 'vacation_paid_days_remaining', 'vacation_days_taken_this_year', 'vacation_saved_days_by_year', 'opening_semester_liability', 'opening_semester_liability_avgifter', 'karens_periods_adjustment', ] as const const patches = rawItems.map((raw) => { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new Error('Invalid opening balances: every item must be an object') } const item = raw as Record const patch: Record = {} for (const field of MERGEABLE_FIELDS) { // Present-with-zero is the explicit clear; absent is "leave alone". if (item[field] !== undefined) patch[field] = item[field] } return { employee_id: item.employee_id, cutover_date: item.cutover_date, patch } }) const employeeIds = patches .map((p) => p.employee_id) .filter((id): id is string => typeof id === 'string') const { data: storedRows, error: storedErr } = await supabase .from('employee_opening_balances') .select(`employee_id, ${MERGEABLE_FIELDS.join(', ')}`) .eq('company_id', companyId) .in('employee_id', employeeIds) if (storedErr) throw new Error(`Database error: ${storedErr.message}`) const storedByEmployee = new Map( ((storedRows ?? []) as unknown as Array>).map((r) => [ r.employee_id as string, r, ]), ) const mergedItems = patches.map((p) => { const stored = storedByEmployee.get(p.employee_id as string) const carried: Record = {} for (const field of MERGEABLE_FIELDS) { // The null-skip cannot drop a legitimately stored value: every // mergeable column is NOT NULL with a default (migration // 20260713101000), so a stored row never holds NULL here. If a // future migration adds a NULLABLE mergeable column, carry null // through explicitly or the schema .default() resets it on merge. const value = stored?.[field] if (value !== undefined && value !== null) carried[field] = value } // No stored row: the schema defaults still apply, so a first-time // cutover keeps landing on 0 / {} for anything not supplied. return { employee_id: p.employee_id, cutover_date: p.cutover_date, ...carried, ...p.patch, } }) const { OpeningBalancesBulkSchema } = await import('@/lib/api/schemas') const parsed = OpeningBalancesBulkSchema.safeParse({ items: mergedItems }) if (!parsed.success) { const first = parsed.error.issues[0] throw new Error(`Invalid opening balances: ${first ? `${first.path.join('.')}: ${first.message}` : 'validation failed'}`) } // Preflight via the shared service in dry-run: employee existence, // employment_start ordering, lock state. Fails staging early with the // full per-item error list. const { setOpeningBalancesBulk } = await import('@/lib/salary/opening-balances') const preflight = await setOpeningBalancesBulk(supabase, { companyId, userId, items: parsed.data.items, dryRun: true, }) if (!preflight.ok) { const itemSummary = preflight.itemErrors ?.map((e) => `${e.employee_id}: ${e.message}`) .join('; ') throw new Error(`Cannot set opening balances: ${itemSummary ?? preflight.code}`) } return stagePendingOperation( supabase, companyId, userId, 'set_employee_opening_balances', `Ingående lönesaldon: ${parsed.data.items.length} anställd(a)`, { items: parsed.data.items }, { employee_count: parsed.data.items.length, cutover_dates: [...new Set(parsed.data.items.map((i) => i.cutover_date))], total_ytd_gross: parsed.data.items.reduce((s, i) => s + (i.ytd_gross || 0), 0), total_opening_liability: parsed.data.items.reduce( (s, i) => s + (i.opening_semester_liability || 0), 0, ), // The merge is part of what gets approved, so make it visible: how // many rows are new vs corrected, and which fields the caller // actually supplied (everything else was carried over unchanged). new_rows: patches.filter((p) => !storedByEmployee.has(p.employee_id as string)).length, updated_rows: patches.filter((p) => storedByEmployee.has(p.employee_id as string)).length, fields_provided: [...new Set(patches.flatMap((p) => Object.keys(p.patch)))].sort(), }, actor, { description: 'After approval, import pre-cutover absence history if needed, then create the first salary run.', tool: 'gnubok_create_salary_run', }, ) }, }, { name: 'gnubok_get_vacation_balance', title: 'Get Vacation Balance (Semestersaldo)', description: 'Get one employee\'s current vacation balance: entitled/taken/remaining days, sparade dagar per origin year (5-year rule), forced payouts, and an estimated semesterlöneskuld in SEK. Ledger seeds on first booking. Use before gnubok_close_vacation_year.', inputSchema: { type: 'object', additionalProperties: false, properties: { employee_id: { type: 'string', description: 'UUID of the employee' }, }, required: ['employee_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { employee_vacation_balance_id: { type: 'string' }, employee_id: { type: 'string' }, vacation_year_start: { type: 'string' }, entitled_days: { type: 'number' }, accrued_days: { type: 'number' }, taken_days: { type: 'number' }, remaining_days: { type: 'number' }, saved_days: { type: 'object', description: 'Origin year -> days' }, forced_payout_days: { type: 'number' }, }, required: ['employee_vacation_balance_id', 'employee_id', 'vacation_year_start', 'entitled_days', 'taken_days', 'remaining_days'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const employeeId = args.employee_id as string if (!employeeId) throw new Error('employee_id is required') const { data: balance, error } = await supabase .from('employee_vacation_balances') .select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days') .eq('company_id', companyId) .eq('employee_id', employeeId) .eq('status', 'open') .order('vacation_year_start', { ascending: false }) .limit(1) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!balance) throw new Error('No vacation balance exists for the employee yet (the ledger seeds on first booking)') const { id, ...rest } = balance as { id: string } & Record const entitled = (rest.entitled_days as number) ?? 0 const taken = (rest.taken_days as number) ?? 0 return { employee_vacation_balance_id: id, ...rest, remaining_days: roundOre(entitled - taken), } }, }, { name: 'gnubok_close_vacation_year', title: 'Close Vacation Year (Semesterårsavslut)', description: 'Stage the vacation year close: rolls balances into the next year (min-20 floor, 5-year expiry to forced payout) and books a 2920/2940 drift adjustment when needed. High risk: review the preview report, then commit via gnubok_approve_pending_operation.', inputSchema: { type: 'object', additionalProperties: false, properties: { vacation_year_start: { type: 'string', description: 'YYYY-MM-DD; defaults to the most recently ended vacation year' }, book_adjustment: { type: 'boolean', description: 'Book the 2920/2940 drift verifikat (default true)' }, }, }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const { previewVacationYearClose } = await import('@/lib/salary/semesterberedning') const { getVacationYearBasis } = await import('@/lib/salary/vacation-ledger') const { getClosableYearStart } = await import('@/lib/salary/vacation-year') let yearStart = args.vacation_year_start as string | undefined if (!yearStart) { const basis = await getVacationYearBasis(supabase, companyId) yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis) } const bookAdjustment = args.book_adjustment !== false // Preflight: the full review report. Staging fails early on // not-ended / already-closed years, and the approver sees the exact // day transitions + SEK drift that will commit. const preview = await previewVacationYearClose(supabase, companyId, yearStart) if (!preview.ok) { throw new Error(`Cannot close vacation year: ${preview.code}`) } const report = preview.data return stagePendingOperation( supabase, companyId, userId, 'vacation_year_close', `Semesterårsavslut ${yearStart.slice(0, 4)} (${report.rows.length} anställda)`, { vacation_year_start: yearStart, book_adjustment: bookAdjustment }, { vacation_year_start: yearStart, vacation_year_end: report.vacation_year_end, employee_count: report.rows.length, total_saveable_days: report.rows.reduce((s, r) => s + r.saveable_days, 0), total_expiring_days: report.rows.reduce((s, r) => s + r.expiring_days, 0), computed_liability: report.sek.computed_liability, computed_avgifter: report.sek.computed_avgifter, booked_2920: report.sek.booked_2920, booked_2940: report.sek.booked_2940, drift_2920: report.sek.drift_2920, drift_2940: report.sek.drift_2940, adjustment_needed: report.sek.adjustment_needed, }, actor, { description: 'After approval, pay out any forced-payout days as semesterersättning in the next salary run.', tool: 'gnubok_create_salary_run', }, { dateForPeriodCheck: report.adjustment_date }, ) }, }, // ── Stream 1 Phase 1: Bookkeeping write (high-risk, always staged) ── { name: 'gnubok_close_period', title: 'Close Fiscal Period', description: 'Stage period close (irreversible per BFL). Requires period locked + year-end closing entry posted. High-risk: always staged, never auto-committed.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to close' }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: period, error: fetchError } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (fetchError || !period) throw new Error('Fiscal period not found') if (period.is_closed) throw new Error('Period is already closed') if (!period.locked_at) throw new Error('Period must be locked before closing: call gnubok_lock_period first') if (!period.closing_entry_id) throw new Error('Year-end closing entry must exist before the period can be closed') return stagePendingOperation(supabase, companyId, userId, 'close_period', `Stäng period: ${period.name} (${period.period_start} till ${period.period_end})`, { fiscal_period_id: fiscalPeriodId }, { period_name: period.name, period_start: period.period_start, period_end: period.period_end, locked_at: period.locked_at, closing_entry_id: period.closing_entry_id, irreversible: true, }, actor, { description: 'Closing is irreversible. Verify the balance sheet and income statement first.', tool: 'gnubok_get_balance_sheet', args: { fiscal_period_id: fiscalPeriodId }, } ) }, }, { name: 'gnubok_lock_period', title: 'Lock Fiscal Period', description: 'Stage period lock: blocks new entries. Requires zero untriaged or unbooked business transactions in the period. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to lock' }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: period, error: fetchError } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (fetchError || !period) throw new Error('Fiscal period not found') if (period.is_closed) throw new Error('Period is already closed') if (period.locked_at) throw new Error('Period is already locked') // Same predicate the commit path (lockPeriod in period-service.ts) // enforces, so the approval card can never claim zero unbooked while // the period holds untriaged or unbooked business transactions. Fail // closed: a guard that cannot run must not wave the staging through. let unbooked: { untriaged: number; businessUnbooked: number } try { unbooked = await countUnbookedInPeriod( supabase, companyId, period.period_start, period.period_end, ) } catch (err) { log.error('lock-period staging guard failed, refusing to stage', { companyId, fiscalPeriodId, reason: err instanceof Error ? err.message : String(err), }) // Deliberately matches NEITHER of the two load-bearing phrases below: // an unreachable DB must not send an agent off remediating // transactions (mirrors period-service.ts). throw new Error( 'Kunde inte kontrollera obokförda banktransaktioner i perioden. Ingen låsning har föreslagits. Försök igen.' ) } const blockingCount = unbooked.untriaged + unbooked.businessUnbooked if (blockingCount > 0) { // Wording mirrors lockPeriod in period-service.ts and is load-bearing: // "saknar bokföring" and /Kan inte låsa period:.*affärstransaktion/ // both feed matchers (inferCode in lib/errors/get-structured-error.ts // derives PERIOD_HAS_UNBOOKED_TRANSACTIONS for the MCP surface). const breakdown = [ unbooked.untriaged > 0 ? `${unbooked.untriaged} ej hanterade` : null, unbooked.businessUnbooked > 0 ? `${unbooked.businessUnbooked} markerade som affärshändelse men utan verifikat` : null, ] .filter(Boolean) .join(', ') throw new Error( `Kan inte låsa period: ${blockingCount} banktransaktion(er) i perioden saknar bokföring ` + `(${breakdown}). Alla affärstransaktioner måste vara bokförda innan perioden låses. ` + `Bokför dem eller markera dem som privata eller ignorerade, och lås perioden därefter.` ) } return stagePendingOperation(supabase, companyId, userId, 'lock_period', `Lås period: ${period.name} (${period.period_start} till ${period.period_end})`, { fiscal_period_id: fiscalPeriodId }, { period_name: period.name, period_start: period.period_start, period_end: period.period_end, // Both guard legs verified zero above; the commit path re-checks via // lockPeriod, so this figure can never silently go stale. unbooked_business_transactions: 0, untriaged_transactions: 0, }, actor, { description: 'After locking, run year-end closing before the period can be closed via gnubok_close_period. Verify balances first with gnubok_get_trial_balance.', tool: 'gnubok_get_trial_balance', args: { fiscal_period_id: fiscalPeriodId }, } ) }, }, { name: 'gnubok_uncategorize_transaction', title: 'Uncategorize Transaction', description: 'Stage uncategorize: reverses linked journal entry via storno (never deletes) and clears the category. Stages for approval.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string', description: 'UUID of the transaction to uncategorize' }, }, required: ['transaction_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string if (!transactionId) throw new Error('transaction_id is required') const { data: tx, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, date, category, journal_entry_id') .eq('id', transactionId) .eq('company_id', companyId) .single() if (txError || !tx) throw new Error('Transaction not found') if (!tx.journal_entry_id) throw new Error('Transaction has no journal entry to reverse') const { data: entry } = await supabase .from('journal_entries') .select('id, voucher_number, voucher_series, status') .eq('id', tx.journal_entry_id) .eq('company_id', companyId) .single() if (!entry || entry.status !== 'posted') { throw new Error('Linked journal entry is not posted: nothing to reverse') } return stagePendingOperation(supabase, companyId, userId, 'uncategorize_transaction', `Återta kategorisering: ${tx.merchant_name || tx.description || transactionId}`, { transaction_id: transactionId, journal_entry_id: tx.journal_entry_id }, { transaction_description: tx.merchant_name || tx.description, amount: tx.amount, currency: tx.currency, date: tx.date, current_category: tx.category, will_reverse_voucher: `${entry.voucher_series}${entry.voucher_number}`, method: 'storno (reversal entry, never deletes)', }, actor, { description: 'After approval the transaction is uncategorized again: book it with the correct category via gnubok_categorize_transaction.', tool: 'gnubok_categorize_transaction', args: { transaction_id: transactionId }, } ) }, }, { name: 'gnubok_export_sie', title: 'Export SIE File', description: 'Generate SIE-4 file for a fiscal period (standard Swedish bookkeeping interchange format). Returns SIE text content.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to export' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { content: { type: 'string' }, byte_size: { type: 'number' }, fiscal_period_id: { type: 'string' }, company_name: { type: 'string' }, generated_at: { type: 'string' }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: company } = await supabase .from('company_settings') .select('company_name, org_number') .eq('company_id', companyId) .single() if (!company) throw new Error('Company settings not found') const sieContent = await generateSIEExport(supabase, companyId, { fiscal_period_id: fiscalPeriodId, company_name: company.company_name || 'Unknown', org_number: company.org_number, }) return { content: sieContent, byte_size: Buffer.byteLength(sieContent, 'utf8'), fiscal_period_id: fiscalPeriodId, company_name: company.company_name, org_number: company.org_number, generated_at: new Date().toISOString(), } }, }, { name: 'gnubok_generate_rot_rut_file', title: 'Generate Rot/Rut Payout File', description: 'Begäran om utbetalning for rot/rut (Skatteverket husavdrag): XML file from paid deduction invoices, uploaded manually on skatteverket.se (no API exists). Call with list_only=true first to see eligible invoices and blockers. Generating records an active begäran per invoice.', inputSchema: { type: 'object', additionalProperties: false, properties: { deduction_type: { type: 'string', enum: ['rot', 'rut'] }, list_only: { type: 'boolean', description: 'Only list eligible + blocked invoices, generate nothing (default false)', }, invoice_ids: { type: 'array', items: { type: 'string' }, description: 'Invoices to include. Omitted = all currently eligible.', }, name: { type: 'string', maxLength: 16, description: 'NamnPaBegaran shown in Skatteverkets e-tjänst (max 16 chars). Omitted = generated.', }, }, required: ['deduction_type'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { deduction_type: { type: 'string' }, eligible: { type: 'array', items: { type: 'object' } }, blocked: { type: 'array', items: { type: 'object' }, description: 'Invoices excluded from begäran with per-invoice blocker code + Swedish message', }, generated: { type: 'boolean' }, request_id: { type: ['string', 'null'] }, file_name: { type: ['string', 'null'] }, xml: { type: ['string', 'null'], description: 'File content: save as UTF-8 .xml and upload on skatteverket.se' }, requested_total: { type: 'number' }, arenden: { type: 'array', items: { type: 'object' } }, warnings: { type: 'array', items: { type: 'string' } }, upload_url: { type: 'string' }, }, required: ['deduction_type', 'generated'], }, annotations: { readOnlyHint: false, // records a rot_rut_payout_requests row when generating destructiveHint: false, idempotentHint: false, // second call conflicts (one active begäran per invoice) openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const type = args.deduction_type as 'rot' | 'rut' if (type !== 'rot' && type !== 'rut') throw new Error('deduction_type must be rot or rut') const uploadUrl = 'https://www7.skatteverket.se/portal/rotrut/begar-utbetalning/fil' const candidates = await listRotRutCandidates(supabase, companyId, type) if (!candidates.ok) throw new Error('Failed to list rot/rut candidates') if (args.list_only === true) { return { deduction_type: type, eligible: candidates.eligible, blocked: candidates.blocked, generated: false, request_id: null, file_name: null, xml: null, requested_total: candidates.eligible.reduce((sum, e) => sum + e.begart_belopp, 0), warnings: [], upload_url: uploadUrl, } } const requestedIds = Array.isArray(args.invoice_ids) && args.invoice_ids.length > 0 ? (args.invoice_ids as string[]) : candidates.eligible.map((e) => e.invoice_id) if (requestedIds.length === 0) { return { deduction_type: type, eligible: [], blocked: candidates.blocked, generated: false, request_id: null, file_name: null, xml: null, requested_total: 0, warnings: ['Inga fakturor är redo att begäras. Se blocked för orsaker per faktura.'], upload_url: uploadUrl, } } const result = await createRotRutPayoutRequest(supabase, companyId, userId, { type, invoiceIds: requestedIds, name: typeof args.name === 'string' ? args.name : undefined, }) if (!result.ok) { const blockerLines = (result.blockers ?? []) .map((b) => `${b.invoice_number ?? b.invoice_id}: ${b.message}`) .join(' | ') throw new Error( result.code === 'ROT_RUT_INVOICE_CONFLICT' ? 'Minst en faktura ingår redan i en aktiv begäran om utbetalning.' : `Filen kunde inte skapas (${result.code}).${blockerLines ? ` ${blockerLines}` : ''}`, ) } return { deduction_type: type, eligible: candidates.eligible, blocked: candidates.blocked, generated: true, request_id: result.request.id as string, file_name: result.file.file_name, xml: result.file.xml, requested_total: result.file.requested_total, arenden: result.file.arenden, warnings: result.file.warnings, upload_url: uploadUrl, } }, }, { name: 'gnubok_import_rot_rut_beslut', title: 'Import Rot/Rut Decision File', description: 'Import Skatteverkets beslutsfil (decision JSON from the rot/rut e-tjänst) and record godkänt belopp on the matching begäran. Exact matching only; per-beslut outcomes in results. Book the payout afterwards via the settle endpoint hint in next.', inputSchema: { type: 'object', additionalProperties: false, properties: { file_content: { type: 'string', description: 'The beslutsfil content verbatim (JSON text as downloaded from skatteverket.se)', }, }, required: ['file_content'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { imported: { type: 'number' }, already_imported: { type: 'number' }, errors: { type: 'number' }, results: { type: 'array', items: { type: 'object' }, description: 'Per-beslut outcome: status imported/already_imported/error, request_id, decided_total, rejected flag, next-step hint', }, }, required: ['imported', 'already_imported', 'errors', 'results'], }, annotations: { readOnlyHint: false, // records beslut on rot_rut_payout_requests destructiveHint: false, idempotentHint: true, // re-importing the same file reports already_imported openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const raw = args.file_content as string if (typeof raw !== 'string' || raw.trim() === '') { throw new Error('file_content is required (the beslutsfil JSON text)') } let parsed: unknown try { parsed = JSON.parse(raw) } catch { throw new Error('file_content är inte giltig JSON. Klistra in beslutsfilen oförändrad.') } const validated = RotRutBeslutFileSchema.safeParse(parsed) if (!validated.success) { throw new Error( `Beslutsfilen har fel format: ${validated.error.issues .slice(0, 3) .map((i) => `${i.path.join('.')}: ${i.message}`) .join('; ')}`, ) } const result = await importRotRutBeslutFile(supabase, companyId, validated.data) if (!result.ok) { throw new Error( result.code === 'ROT_RUT_BESLUT_WRONG_COMPANY' ? 'Beslutsfilens utförare matchar inte företagets organisationsnummer.' : 'Beslutsfilen kunde inte importeras.', ) } return { imported: result.imported, already_imported: result.already_imported, errors: result.errors, results: result.results, } }, }, { name: 'gnubok_audit_package', title: 'Generate Audit Package', description: "Single-call audit package for a fiscal period: SIE-4 + reports (trial balance, income statement, balance sheet, general ledger, journal, VAT) + receipts + audit log + voucher gaps, zipped. 1-hour signed URL.", inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to package' }, include_documents: { type: 'boolean', description: 'Include receipts/document binaries in the zip (default true)' }, estimate_only: { type: 'boolean', description: 'Return size estimate without generating (default false)' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { download_url: { type: ['string', 'null'], description: 'Signed Supabase Storage URL valid for 1 hour. Null when estimate_only=true.' }, storage_path: { type: ['string', 'null'] }, file_name: { type: 'string' }, size_bytes: { type: 'number' }, size_limit_bytes: { type: 'number' }, within_limit: { type: 'boolean' }, period: { type: 'object' }, generated_at: { type: 'string' }, expires_at: { type: ['string', 'null'] }, estimate_only: { type: 'boolean' }, }, required: ['file_name', 'size_bytes', 'period', 'generated_at', 'estimate_only'], }, annotations: { readOnlyHint: false, // produces a Storage artifact destructiveHint: false, idempotentHint: true, // repeat calls produce equivalent archives, fresh URL openWorldHint: false, }, // Archive generation is the one genuinely long-running synchronous call // in the catalog: task-capable clients get a durable handle instead of a // multi-minute blocking response. Size estimates stay synchronous. shouldRunAsTask: (args) => args.estimate_only !== true, async execute(args, companyId, userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const includeDocuments = args.include_documents !== false const estimateOnly = args.estimate_only === true const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 // Verify period belongs to the company const { data: period, error: periodErr } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (periodErr || !period) throw new Error('Fiscal period not found') const generatedAt = new Date().toISOString() // Pre-flight size estimate: also serves the estimate-only path const estimate = await estimateArchiveSize(supabase, companyId, 'period', fiscalPeriodId) const sizeBytes = estimate.total_bytes const withinLimit = sizeBytes <= SIZE_LIMIT_BYTES const fileName = `arkiv_${period.name.replace(/[^\w-]/g, '_')}_${fiscalPeriodId.slice(0, 8)}.zip` if (estimateOnly) { return { download_url: null, storage_path: null, file_name: fileName, size_bytes: sizeBytes, size_limit_bytes: SIZE_LIMIT_BYTES, within_limit: withinLimit, period: { id: period.id, name: period.name, period_start: period.period_start, period_end: period.period_end, }, generated_at: generatedAt, expires_at: null, estimate_only: true, } } if (includeDocuments && !withinLimit) { throw new Error( `Archive would exceed ${Math.round(SIZE_LIMIT_BYTES / 1024 / 1024)} MB (estimate: ${Math.round(sizeBytes / 1024 / 1024)} MB). Retry with include_documents=false to omit receipt binaries.` ) } // Generate the archive (long-running) const zipBuffer = await generateFullArchive(supabase, companyId, { scope: 'period', period_id: fiscalPeriodId, include_documents: includeDocuments, }) // Upload to Storage under a per-user audit-packages folder const storagePath = `${userId}/audit-packages/${Date.now()}_${fileName}` const { error: uploadErr } = await supabase.storage .from('documents') .upload(storagePath, new Uint8Array(zipBuffer), { contentType: 'application/zip', upsert: false, }) if (uploadErr) throw new Error(`Failed to upload archive: ${uploadErr.message}`) // Sign for 1 hour const SIGNED_URL_TTL_SECONDS = 3600 const { data: signed, error: signErr } = await supabase.storage .from('documents') .createSignedUrl(storagePath, SIGNED_URL_TTL_SECONDS) if (signErr || !signed) { // Best-effort cleanup of the uploaded blob if signing failed await supabase.storage.from('documents').remove([storagePath]) throw new Error(`Failed to sign archive URL: ${signErr?.message ?? 'unknown error'}`) } const expiresAt = new Date(Date.now() + SIGNED_URL_TTL_SECONDS * 1000).toISOString() return { download_url: signed.signedUrl, storage_path: storagePath, file_name: fileName, size_bytes: zipBuffer.byteLength, size_limit_bytes: SIZE_LIMIT_BYTES, within_limit: true, period: { id: period.id, name: period.name, period_start: period.period_start, period_end: period.period_end, }, generated_at: generatedAt, expires_at: expiresAt, estimate_only: false, } }, }, // ── Stream 1 Phase 1 follow-up: year-end, opening balances, revaluation, // voucher gaps, supplier-invoice lifecycle, proforma conversion ── { name: 'gnubok_year_end_readiness', title: 'Year-End Readiness Check', description: "Pre-flight before irreversible gnubok_run_year_end. Returns ready (bool) + ordered blockers (drafts, voucher gaps, sequence mismatches, unbalanced TB, FX revaluation) + optional closing-entry preview.", inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to year-end' }, include_preview: { type: 'boolean', description: 'If true, also return the would-be closing journal entry preview (default false)' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { period: { type: 'object' }, ready: { type: 'boolean' }, blockers: { type: 'array', items: { type: 'object' } }, warnings: { type: 'array', items: { type: 'string' } }, draft_count: { type: 'number' }, unexplained_voucher_gap_count: { type: 'number' }, sequence_mismatch_count: { type: 'number' }, trial_balance_balanced: { type: 'boolean' }, preview: { type: ['object', 'null'] }, summary: { type: 'string' }, }, required: ['ready', 'blockers', 'warnings', 'summary'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string const includePreview = args.include_preview === true if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') // Fetch period for context (the validate function returns errors if not found, // but agents benefit from period metadata in the response) const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id, continuity_verified') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found') const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId) // Reshape error strings into structured blockers so the agent (and any // dashboard) can render and act on each one independently. The lib // returns flat strings; we tag each with a `kind` heuristic for routing. // validateYearEndReadiness emits Swedish messages (the bokslut wizard // renders them verbatim); English alternates are kept as fallback so // classification never regresses if an older message slips through. const blockers = validation.errors.map((message) => { let kind: string = 'other' if (/draft journal entries|utkast måste bokföras/i.test(message)) kind = 'draft_entries' else if (/unbooked transaction|saknar bokföring|obokförda transaktioner/i.test(message)) kind = 'unbooked_transactions' else if (/voucher gap|verifikationsnummerglapp/i.test(message)) kind = 'unexplained_voucher_gap' else if (/Sequence counter integrity|Nummerserien i serie/i.test(message)) kind = 'sequence_mismatch' else if (/Trial balance is not balanced|Råbalansen balanserar inte/i.test(message)) kind = 'trial_balance_unbalanced' else if (/already closed|redan stängd/i.test(message)) kind = 'period_already_closed' else if (/has not yet ended|slutdatumet har inte passerat/i.test(message)) kind = 'period_not_ended' else if (/closing entry already exists|Bokslutsverifikation finns redan/i.test(message)) kind = 'closing_entry_exists' else if (/continuity check failed|IB\/UB-kontinuiteten/i.test(message)) kind = 'opening_balance_continuity' else if (/opening balances already posted|redan ingående balanser bokförda/i.test(message)) kind = 'next_period_ib_posted' else if (/Fiscal period not found|Räkenskapsperioden hittades inte/i.test(message)) kind = 'period_not_found' return { kind, severity: 'high' as const, message } }) let preview = null if (includePreview && validation.ready) { try { preview = await previewYearEndClosing(supabase, companyId, userId, fiscalPeriodId) } catch (err) { // Preview is opportunistic: never fail the readiness check on it. preview = { error: err instanceof Error ? err.message : 'Preview unavailable' } } } const summary = validation.ready ? validation.warnings.length > 0 ? `Klart för bokslut. ${validation.warnings.length} varning(ar) att granska.` : 'Klart för bokslut.' : `Inte klart: ${blockers.length} blockerare måste åtgärdas.` return { period: { id: period.id, name: period.name, period_start: period.period_start, period_end: period.period_end, is_closed: period.is_closed, locked_at: period.locked_at, closing_entry_id: period.closing_entry_id, continuity_verified: period.continuity_verified, }, ready: validation.ready, blockers, warnings: validation.warnings, draft_count: validation.draftCount, unexplained_voucher_gap_count: validation.unexplainedGaps.length, sequence_mismatch_count: validation.sequenceMismatches.length, trial_balance_balanced: validation.trialBalanceBalanced, preview, summary, } }, }, { name: 'gnubok_run_year_end', title: 'Run Year-End Closing (Bokslut)', description: 'Stage year-end closing: zero result accounts (class 3-8) into 2099, lock period, create next period, seed opening balances. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to close out' }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at') .eq('id', fiscalPeriodId).eq('company_id', companyId).single() if (!period) throw new Error('Fiscal period not found') if (period.is_closed) throw new Error('Period is already closed') return stagePendingOperation(supabase, companyId, userId, 'run_year_end', `Bokslut: ${period.name}`, { fiscal_period_id: fiscalPeriodId }, { period_name: period.name, period_start: period.period_start, period_end: period.period_end, will: 'zero result accounts into 2099, lock period, create next period, generate opening balances', }, actor, { description: 'After year-end, the period is locked and ready for closing via gnubok_close_period.', tool: 'gnubok_close_period', args: { fiscal_period_id: fiscalPeriodId }, } ) }, }, { name: 'gnubok_set_opening_balances', title: 'Set Opening Balances (Ingående Balans)', description: 'Stage opening-balance entry: copy class 1-2 closing balances from a closed period into the next period.', inputSchema: { type: 'object', additionalProperties: false, properties: { closed_period_id: { type: 'string', description: 'UUID of the closed source period' }, next_period_id: { type: 'string', description: 'UUID of the next (target) period' }, }, required: ['closed_period_id', 'next_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const closedId = args.closed_period_id as string const nextId = args.next_period_id as string if (!closedId || !nextId) throw new Error('closed_period_id and next_period_id are required') // Resolve human-readable period names so the approver doesn't see two raw // UUIDs in the staged-ops list. Both lookups are scoped to the company so // a mis-typed UUID from another tenant just yields a thin (but safe) title. const [{ data: closed }, { data: next }] = await Promise.all([ supabase.from('fiscal_periods').select('name, period_end').eq('id', closedId).eq('company_id', companyId).maybeSingle(), supabase.from('fiscal_periods').select('name, period_start').eq('id', nextId).eq('company_id', companyId).maybeSingle(), ]) const closedLabel = closed?.name ?? closedId const nextLabel = next?.name ?? nextId return stagePendingOperation(supabase, companyId, userId, 'set_opening_balances', `Ingående balans: ${closedLabel} → ${nextLabel}`, { closed_period_id: closedId, next_period_id: nextId }, { closed_period_id: closedId, closed_period_name: closed?.name ?? null, next_period_id: nextId, next_period_name: next?.name ?? null, will: 'create opening balance entry from closed-period trial balance', }, actor, { description: 'After approval, verify the opening balance matches the closed period\'s UB via gnubok_get_trial_balance on the next period.', tool: 'gnubok_get_trial_balance', args: { fiscal_period_id: nextId }, } ) }, }, { name: 'gnubok_run_currency_revaluation', title: 'Run Currency Revaluation', description: 'Stage currency revaluation: revalue open FX receivables/payables to closing-date rate (posts 3960/7960). One per period max.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, closing_date: { type: 'string', description: 'Revaluation date (YYYY-MM-DD)' }, }, required: ['fiscal_period_id', 'closing_date'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string const closingDate = args.closing_date as string if (!fiscalPeriodId || !closingDate) throw new Error('fiscal_period_id and closing_date are required') return stagePendingOperation(supabase, companyId, userId, 'run_currency_revaluation', `Valutaomvärdering ${closingDate}`, { fiscal_period_id: fiscalPeriodId, closing_date: closingDate }, { fiscal_period_id: fiscalPeriodId, closing_date: closingDate, posts_to: ['3960', '7960'] }, actor, { description: 'After approval, confirm the new FX-adjusted balances via gnubok_get_balance_sheet.', tool: 'gnubok_get_balance_sheet', args: { fiscal_period_id: fiscalPeriodId }, } ) }, }, { name: 'gnubok_list_voucher_gaps', title: 'List Voucher Gaps', description: 'List voucher number gaps in a fiscal period (BFNAR 2013:2 audit requirement). Each gap shows whether it has an explanation.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string' }, voucher_series: { type: 'string', description: 'Optional series filter (e.g. "A")' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { gaps: { type: 'array', items: { type: 'object' } }, total_gaps: { type: 'number' }, unexplained_gaps: { type: 'number' }, }, required: ['gaps', 'total_gaps', 'unexplained_gaps'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string const voucherSeries = args.voucher_series as string | undefined let seriesQuery = supabase .from('voucher_sequences').select('voucher_series') .eq('company_id', companyId).eq('fiscal_period_id', fiscalPeriodId) if (voucherSeries) seriesQuery = seriesQuery.eq('voucher_series', voucherSeries) const { data: seriesRows } = await seriesQuery if (!seriesRows || seriesRows.length === 0) { return { gaps: [], total_gaps: 0, unexplained_gaps: 0 } } const allGaps: Array<{ series: string; gap_start: number; gap_end: number; explanation: unknown }> = [] for (const row of seriesRows) { const { data: gaps } = await supabase.rpc('detect_voucher_gaps', { p_company_id: companyId, p_fiscal_period_id: fiscalPeriodId, p_series: row.voucher_series, }) if (gaps) { for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) { allGaps.push({ series: row.voucher_series, gap_start: gap.gap_start, gap_end: gap.gap_end, explanation: null }) } } } if (allGaps.length > 0) { const { data: explanations } = await supabase .from('voucher_gap_explanations') .select('id, voucher_series, gap_start, gap_end, explanation, created_at') .eq('company_id', companyId).eq('fiscal_period_id', fiscalPeriodId) if (explanations) { const map = new Map(explanations.map((e) => [`${e.voucher_series}:${e.gap_start}:${e.gap_end}`, e])) for (const g of allGaps) { g.explanation = map.get(`${g.series}:${g.gap_start}:${g.gap_end}`) ?? null } } } return { gaps: allGaps, total_gaps: allGaps.length, unexplained_gaps: allGaps.filter((g) => !g.explanation).length, } }, }, { name: 'gnubok_set_voucher_note', title: 'Set Voucher Note (Anteckning)', description: 'Stage setting, replacing or clearing the internal note (anteckning) on a verifikat. Notes are annotation metadata, editable even on posted entries: bookkeeping fields stay immutable. Read them via gnubok_query_journal (entry_notes).', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { journal_entry_id: { type: 'string', description: 'Verifikat UUID (find via gnubok_query_journal).' }, notes: { type: ['string', 'null'], description: 'New note (max 2000 chars), replaces the old one; null or empty clears.', }, dry_run: { type: 'boolean', description: 'Validate and preview without staging.' }, idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' }, }, required: ['journal_entry_id', 'notes'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const journalEntryId = String(args.journal_entry_id ?? '').trim() if (!journalEntryId) throw new Error('journal_entry_id is required') if (args.notes !== null && typeof args.notes !== 'string') { throw new Error('notes must be a string (max 2000 chars) or null to clear the note') } // Whitespace-only → null so the column never stores visually-empty // annotations (same normalisation as the commit-boundary schema). const notes = typeof args.notes === 'string' && args.notes.trim() !== '' ? args.notes : null if (notes !== null && notes.length > 2000) { throw new Error('notes must be 2000 characters or shorter') } const { data: entry, error: fetchErr } = await supabase .from('journal_entries') .select('id, voucher_series, voucher_number, entry_date, description, status, notes') .eq('id', journalEntryId) .eq('company_id', companyId) .maybeSingle() if (fetchErr) throw new Error(`Database error: ${fetchErr.message}`) if (!entry) throw new Error('Verifikationen hittades inte.') const voucherLabel = entry.voucher_number ? `${entry.voucher_series ?? ''}${entry.voucher_number}` : 'utkast' return stagePendingOperation(supabase, companyId, userId, 'set_voucher_note', notes === null ? `Rensa anteckning på verifikat ${voucherLabel}` : `Anteckning på verifikat ${voucherLabel}`, { journal_entry_id: journalEntryId, notes }, { journal_entry_id: journalEntryId, voucher: voucherLabel, entry_description: entry.description, entry_status: entry.status, old_notes: entry.notes ?? null, new_notes: notes, }, actor, undefined, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, dateForPeriodCheck: entry.entry_date, } ) }, }, { name: 'gnubok_explain_voucher_gap', title: 'Explain Voucher Gap', description: 'Stage explanation for a voucher gap (BFNAR 2013:2 compliance, every gap needs a documented reason).', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string' }, voucher_series: { type: 'string' }, gap_start: { type: 'number' }, gap_end: { type: 'number' }, explanation: { type: 'string', description: 'Swedish prose: why the gap exists' }, }, required: ['fiscal_period_id', 'voucher_series', 'gap_start', 'gap_end', 'explanation'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const explanation = args.explanation as string if (!explanation?.trim()) throw new Error('explanation is required') return stagePendingOperation(supabase, companyId, userId, 'explain_voucher_gap', `Förklara verifikationslucka ${args.voucher_series}:${args.gap_start}-${args.gap_end}`, { fiscal_period_id: args.fiscal_period_id, voucher_series: args.voucher_series, gap_start: args.gap_start, gap_end: args.gap_end, explanation: explanation.trim(), }, { voucher_series: args.voucher_series, gap_start: args.gap_start, gap_end: args.gap_end, explanation: explanation.trim(), }, actor, { description: 'After approval, run gnubok_list_voucher_gaps again to confirm all gaps in the period now have explanations (BFNAR 2013:2).', tool: 'gnubok_list_voucher_gaps', args: { fiscal_period_id: args.fiscal_period_id }, } ) }, }, { name: 'gnubok_approve_supplier_invoice', title: 'Approve Supplier Invoice', description: 'Stage approval of a supplier invoice that has not been attested yet (registered or overdue). An invoice that is still past its due date keeps the overdue label after approval. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { supplier_invoice_id: { type: 'string' } }, required: ['supplier_invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.supplier_invoice_id as string if (!id) throw new Error('supplier_invoice_id is required') const { data: inv } = await supabase .from('supplier_invoices') .select('id, supplier_invoice_number, invoice_date, total, currency, status, approved_at, supplier:suppliers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Supplier invoice not found') // 'overdue' is approvable: the daily cron puts unbooked invoices there // just by aging (#1206). approved_at is the durable attest marker. if (!canApproveSupplierInvoice(inv)) { throw new Error('Fakturan är redan godkänd eller kan inte godkännas i nuvarande status') } return stagePendingOperation(supabase, companyId, userId, 'approve_supplier_invoice', `Godkänn leverantörsfaktura ${inv.supplier_invoice_number}`, { supplier_invoice_id: id }, { supplier_invoice_number: inv.supplier_invoice_number, supplier_name: (inv.supplier as { name?: string } | null)?.name, total: inv.total, currency: inv.currency, invoice_date: inv.invoice_date, }, actor, { description: 'After approval the invoice is attested and ready for payment. When paid, match the outbound bank transaction via gnubok_match_transaction_to_invoice.', tool: 'gnubok_get_supplier_ledger', }, inv.invoice_date ? { dateForPeriodCheck: inv.invoice_date } : {}, ) }, }, { name: 'gnubok_credit_supplier_invoice', title: 'Credit Supplier Invoice (Kreditfaktura)', description: 'Stage credit-note (kreditfaktura) for a supplier invoice: mirror invoice with negative effect + reverses registration JE (accrual).', inputSchema: { type: 'object', additionalProperties: false, properties: { supplier_invoice_id: { type: 'string' } }, required: ['supplier_invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.supplier_invoice_id as string if (!id) throw new Error('supplier_invoice_id is required') const { data: inv } = await supabase .from('supplier_invoices') .select('id, supplier_invoice_number, total, currency, status, supplier:suppliers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Supplier invoice not found') if (inv.status === 'credited') throw new Error('Fakturan har redan krediterats') return stagePendingOperation(supabase, companyId, userId, 'credit_supplier_invoice', `Kreditera leverantörsfaktura ${inv.supplier_invoice_number}`, { supplier_invoice_id: id }, { supplier_invoice_number: inv.supplier_invoice_number, supplier_name: (inv.supplier as { name?: string } | null)?.name, total: inv.total, currency: inv.currency, method: 'creates KREDIT- mirror invoice + reverses registration JE (accrual)', }, actor, { description: 'After approval the credit note is posted and the leverantörsskuld cleared. Verify with gnubok_get_supplier_ledger.', tool: 'gnubok_get_supplier_ledger', } ) }, }, { name: 'gnubok_convert_invoice', title: 'Convert Proforma to Invoice', description: 'Stage conversion of a proforma invoice to a real invoice. Allocates F-series number, copies items, marks proforma cancelled.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string' } }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.invoice_id as string if (!id) throw new Error('invoice_id is required') const { data: inv } = await supabase .from('invoices') .select('id, document_type, status, total, currency, customer:customers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Invoice not found') if (inv.document_type !== 'proforma') throw new Error('Endast proformafakturor kan konverteras') if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makuleras') const customerName = (inv.customer as { name?: string } | null)?.name ?? 'okänd kund' return stagePendingOperation(supabase, companyId, userId, 'convert_invoice', `Konvertera proforma → faktura: ${customerName} ${Math.round(Number(inv.total) * 100) / 100} ${inv.currency}`, { invoice_id: id }, { customer_name: (inv.customer as { name?: string } | null)?.name, total: inv.total, currency: inv.currency, will: 'allocate F-series number, copy items, cancel proforma', }, actor, { description: 'After conversion, send the new invoice with gnubok_send_invoice.', tool: 'gnubok_send_invoice', } ) }, }, { name: 'gnubok_unlock_period', title: 'Unlock Fiscal Period', description: 'Stage period unlock: clears locked_at so entries can be posted again. Cannot unlock a closed period. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to unlock' }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: period, error: fetchError } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (fetchError || !period) throw new Error('Fiscal period not found') if (period.is_closed) throw new Error('Cannot unlock a closed period') if (!period.locked_at) throw new Error('Period is not locked') return stagePendingOperation(supabase, companyId, userId, 'unlock_period', `Lås upp period: ${period.name} (${period.period_start} till ${period.period_end})`, { fiscal_period_id: fiscalPeriodId }, { period_name: period.name, period_start: period.period_start, period_end: period.period_end, locked_at: period.locked_at, will: 'clear locked_at: new entries can be posted into the period again', }, actor, { description: 'After approval, post the rättelse via gnubok_correct_entry or new entries via gnubok_create_voucher, then re-lock with gnubok_lock_period.', tool: 'gnubok_lock_period', args: { fiscal_period_id: fiscalPeriodId }, } ) }, }, { name: 'gnubok_credit_invoice', title: 'Credit Customer Invoice (Kreditfaktura)', description: 'Stage credit note (kreditfaktura) for a customer invoice: KR- prefixed mirror invoice + reverses original JE (accrual). Original must be sent/paid/overdue and not already credited.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the invoice to credit' }, reason: { type: 'string', description: 'Optional reason note (Swedish, shown on the credit note)' }, }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.invoice_id as string const reason = args.reason as string | undefined if (!id) throw new Error('invoice_id is required') const { data: inv } = await supabase .from('invoices') .select('id, invoice_number, document_type, status, total, currency, customer:customers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Invoice not found') if (inv.document_type && inv.document_type !== 'invoice') { throw new Error('Credit notes can only be created from standard invoices') } if (inv.status === 'credited') throw new Error('Fakturan har redan krediterats') if (!['sent', 'paid', 'overdue'].includes(inv.status)) { throw new Error('Endast skickade, betalda eller förfallna fakturor kan krediteras') } return stagePendingOperation(supabase, companyId, userId, 'credit_invoice', `Kreditera faktura ${inv.invoice_number}`, { invoice_id: id, reason }, { invoice_number: inv.invoice_number, customer_name: (inv.customer as { name?: string } | null)?.name, total: inv.total, currency: inv.currency, reason: reason || null, method: 'creates KR- mirror invoice + reverses original JE (accrual)', }, actor, { description: 'After approval the credit note posts and the kundfordring is cleared. If a refund is owed to the customer, book the outbound payment when it leaves the bank.', tool: 'gnubok_get_ar_ledger', } ) }, }, { name: 'gnubok_update_invoice', title: 'Update Draft Invoice', description: 'Stage an edit to a DRAFT invoice: header fields (incl. default_dimensions) and/or items (items = FULL REPLACE). Drafts only: no verifikat, not self-billed, not a credit note. Sent/paid invoices need gnubok_credit_invoice. Find invoice_id with gnubok_list_invoices.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the draft invoice, from gnubok_list_invoices.' }, notes: { type: 'string' }, invoice_date: { type: 'string', description: 'YYYY-MM-DD' }, due_date: { type: 'string', description: 'YYYY-MM-DD' }, delivery_date: { type: ['string', 'null'], description: 'YYYY-MM-DD; null clears the delivery date.' }, your_reference: { type: 'string' }, our_reference: { type: 'string' }, items: { type: 'array', items: { type: 'object', properties: { description: { type: 'string' }, quantity: { type: 'number' }, unit: { type: 'string', description: 'st, tim, dag, mån' }, unit_price: { type: 'number', description: 'Price per unit excl. VAT' }, vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.', }, }, required: ['description', 'quantity', 'unit', 'unit_price'], }, description: 'FULL REPLACE: when provided, every existing line is deleted and this array becomes the new line set. Omit to keep the current lines.', }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag keyed by SIE dim no, value = code OR name. Replaces the whole stored bag; {} clears all tags. Omit to keep the current bag.', }, dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' }, }, required: ['invoice_id'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required. Use gnubok_list_invoices to find IDs.') const rawItems = args.items as | Array<{ description: string quantity: number unit: string unit_price: number vat_rate?: number dimensions?: unknown }> | undefined if (rawItems !== undefined) { if (!Array.isArray(rawItems) || rawItems.length === 0) { throw new Error('items must be a non-empty array: it fully REPLACES every existing line on the draft.') } for (const [i, item] of rawItems.entries()) { if (!item.description?.trim()) throw new Error(`Item ${i + 1}: description is required`) if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${i + 1}: quantity must be positive`) if (!item.unit?.trim()) throw new Error(`Item ${i + 1}: unit is required (st, tim, dag)`) if (item.unit_price == null) throw new Error(`Item ${i + 1}: unit_price is required`) } } // Resolve-don't-select (same pass as gnubok_create_invoice): parse the // default bag + each item's bag, then resolve codes AND names against // the registry in one go (zero queries when nothing is tagged). const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, [ defaultDimensions, ...(rawItems ?? []).map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`)), ], ) const resolvedDefaultDimensions = resolvedDimBags[0] const stagedItems = rawItems?.map((item, i) => { const { dimensions: _rawDimensions, ...rest } = item const bag = resolvedDimBags[i + 1] return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest }) const changes: Record = {} for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference']) { if (args[key] !== undefined) changes[key] = args[key] } if (stagedItems) changes.items = stagedItems // The bag replaces wholesale, never merges: {} clears every tag. if (args.default_dimensions !== undefined) changes.default_dimensions = resolvedDefaultDimensions ?? {} const parsed = UpdateInvoiceParamsSchema.safeParse({ invoice_id: invoiceId, changes }) if (!parsed.success) { const issue = parsed.error.issues[0] throw new Error(`Invalid invoice update: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`) } const { data: invoice, error } = await supabase .from('invoices') .select('id, invoice_number, status, document_type, journal_entry_id, is_self_billed, credited_invoice_id, total, currency, customer:customers(name)') .eq('id', parsed.data.invoice_id) .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!invoice) throw new Error('Invoice not found. Use gnubok_list_invoices to find valid IDs.') // Editable drafts only: the shared predicate the web PATCH route gates // on. The commit executor re-checks it at approval time (staging is not // a lock: the invoice can be sent between staging and approval). if (!isEditableInvoiceDraft(invoice)) { throw new Error( `Invoice ${invoice.invoice_number ?? invoice.id} is not an editable draft ` + `(status: ${invoice.status}${invoice.journal_entry_id ? ', has a posted verifikat' : ''}` + `${invoice.is_self_billed ? ', self-billed' : ''}${invoice.credited_invoice_id ? ', credit note' : ''}). ` + `Sent, paid, or booked invoices are immutable: use gnubok_credit_invoice instead.` ) } const customerName = (invoice.customer as { name?: string } | null)?.name return stagePendingOperation(supabase, companyId, userId, 'update_invoice', `Uppdatera fakturautkast: ${customerName ?? invoice.invoice_number ?? invoice.id}`, parsed.data, { invoice_id: invoice.id, invoice_number: invoice.invoice_number ?? null, customer_name: customerName ?? null, status: invoice.status, changes: parsed.data.changes, ...(parsed.data.changes.items ? { items_replace: true, item_count: parsed.data.changes.items.length } : {}), ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), }, actor, { description: 'Once approved, the draft is rewritten in place (totals and VAT recomputed; items fully replaced when provided). Send it with gnubok_send_invoice when ready.', tool: 'gnubok_send_invoice', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, { name: 'gnubok_import_sie', title: 'Import SIE File', description: 'Stage SIE-file import (types 1-4, CP437/UTF-8/Latin-1). On commit creates fiscal period, opening balances, and journal entries. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { file_content: { type: 'string', description: 'Full SIE file contents' }, filename: { type: 'string', description: 'Original filename' }, mappings: { type: 'array', description: 'Account mappings: { sourceAccount, sourceName, targetAccount, targetName, confidence, matchType, isOverride }', items: { type: 'object' }, }, create_fiscal_period: { type: 'boolean' }, import_opening_balances: { type: 'boolean' }, import_transactions: { type: 'boolean' }, voucher_series: { type: 'string', description: 'Override voucher series for imported vouchers' }, update_account_names: { type: 'boolean', description: 'Use #KONTO names from the file for created and existing accounts (default true). Set false to keep BAS default names.' }, }, required: ['file_content', 'filename', 'mappings'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fileContent = args.file_content as string const filename = args.filename as string const mappings = args.mappings as unknown[] | undefined if (!fileContent || !filename || !Array.isArray(mappings)) { throw new Error('file_content, filename, and mappings are required') } // Parse + validate at stage time so the approver sees real content (which // entries, what balances) and a broken/unbalanced file is rejected HERE, // not after they approve a blind byte count. commitImportSie re-parses on // commit (defense-in-depth: the staged string could be tampered). const { parseSIEFile, validateSIEFile, getEffectiveOpeningBalances } = await import('@/lib/import/sie-parser') let parsed try { parsed = parseSIEFile(fileContent) } catch (e) { throw new Error(`SIE-filen kunde inte tolkas: ${e instanceof Error ? e.message : 'okänt fel'}`) } const validation = validateSIEFile(parsed) if (!validation.valid) { throw new Error(`SIE-filen är ogiltig och importeras inte: ${validation.errors.join('; ')}`) } // Effective set: explicit #IB 0, or IB derived from #UB -1 when the // source system exports none (issue #675): so the approver sees the // real IB total and UB-1-only files pass the coverage check below. const ibCurrent = getEffectiveOpeningBalances(parsed).balances const ibTotal = Math.round(ibCurrent.reduce((s, b) => s + b.amount, 0) * 100) / 100 // Mapping-coverage check. The executor's per-voucher loop silently // skips any line whose account is not in `mappings`, so an empty or // non-overlapping mapping set produces a committed import with // journal_entries_created=0 that then claims the (company_id, // file_hash) slot in the partial unique index and blocks retry. // Refuse to stage when the mapping wouldn't cover a single account // present in the file. const importOB = Boolean(args.import_opening_balances) const sourceAccountsInFile = new Set() for (const v of parsed.vouchers) for (const l of v.lines) sourceAccountsInFile.add(l.account) if (importOB) for (const b of ibCurrent) sourceAccountsInFile.add(b.account) const mappedSources = new Set( (mappings as Array<{ sourceAccount?: unknown; targetAccount?: unknown }>) .filter((m) => typeof m?.targetAccount === 'string' && m.targetAccount.length > 0 && typeof m?.sourceAccount === 'string') .map((m) => m.sourceAccount as string), ) const coveredAccounts = [...sourceAccountsInFile].filter((a) => mappedSources.has(a)) const accountsMapped = { covered: coveredAccounts.length, total: sourceAccountsInFile.size } const wouldSkipAllVouchers = sourceAccountsInFile.size > 0 && coveredAccounts.length === 0 if (wouldSkipAllVouchers) { const sample = [...sourceAccountsInFile].slice(0, 8).join(', ') throw new Error( `Kontomappningarna täcker inga konton i SIE-filen: alla ` + `${parsed.stats.totalVouchers} verifikationer skulle hoppas över ` + `och importen skulle skapa 0 verifikat. Filen innehåller ` + `${sourceAccountsInFile.size} unika källkonton (t.ex. ${sample}). ` + `Bifoga "mappings" där sourceAccount matchar #KONTO-numren i filen ` + `och targetAccount är ett giltigt BAS-konto.`, ) } return stagePendingOperation(supabase, companyId, userId, 'import_sie', `SIE-import: ${filename}`, { file_content: fileContent, filename, mappings, create_fiscal_period: Boolean(args.create_fiscal_period), import_opening_balances: Boolean(args.import_opening_balances), import_transactions: Boolean(args.import_transactions), voucher_series: args.voucher_series, // Default true: Boolean(undefined) would silently flip it off. update_account_names: args.update_account_names === undefined ? true : Boolean(args.update_account_names), }, { filename, file_size_bytes: fileContent.length, mappings_count: mappings.length, accounts_mapped: accountsMapped, would_skip_all_vouchers: wouldSkipAllVouchers, company_name: parsed.header.companyName, org_number: parsed.header.orgNumber, fiscal_year: { start: parsed.stats.fiscalYearStart, end: parsed.stats.fiscalYearEnd }, account_count: parsed.stats.totalAccounts, voucher_count: parsed.stats.totalVouchers, transaction_line_count: parsed.stats.totalTransactionLines, opening_balance: { total: ibTotal, is_balanced: ibTotal === 0 }, warnings: validation.warnings, create_fiscal_period: Boolean(args.create_fiscal_period), import_opening_balances: Boolean(args.import_opening_balances), import_transactions: Boolean(args.import_transactions), will: 'create fiscal period + opening balances + journal entries from the parsed SIE', }, actor, { description: 'After commit, verify the imported balances with gnubok_get_trial_balance and check continuity via the IB/UB of adjacent periods.', tool: 'gnubok_get_trial_balance', } ) }, }, { name: 'gnubok_undo_sie_import', title: 'Undo SIE Import', description: 'Stage undo of a completed SIE import: hard-deletes its entries, detaches docs, resets voucher_sequences, marks the import \'undone\' for re-import. Use after a botched import. Period must be open. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, properties: { import_id: { type: 'string', description: 'UUID of the sie_imports row to undo. Must be status=\'completed\'.' }, reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason: shown in pending_operations review.' }, }, required: ['import_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const importId = args.import_id as string const reason = typeof args.reason === 'string' ? args.reason : undefined if (!importId) throw new Error('import_id is required') if (reason !== undefined && reason.length > 500) { throw new Error('reason must be 500 characters or fewer') } // Pre-flight mirrors undoSIEImport: confirm row exists, belongs to // this company, is in 'completed' status, and (if linked) the fiscal // period is open + unlocked. Surfacing rejection at stage-time keeps // the agent honest about what the approver is being asked to confirm. type ImportRow = { id: string filename: string fiscal_year_start: string | null fiscal_year_end: string | null transactions_count: number | null opening_balance_entry_id: string | null status: string fiscal_period_id: string | null imported_at: string | null } const { data, error: lookupErr } = await supabase .from('sie_imports') .select('id, filename, fiscal_year_start, fiscal_year_end, transactions_count, opening_balance_entry_id, status, fiscal_period_id, imported_at') .eq('id', importId) .eq('company_id', companyId) .maybeSingle() const importRow = data as ImportRow | null if (lookupErr) { throw new Error(`Kunde inte slå upp SIE-import ${importId}: ${lookupErr.message}`) } if (!importRow) { throw new Error(`SIE-import hittades inte: ${importId}`) } if (importRow.status !== 'completed') { throw new Error(`Bara slutförda importer kan ångras (nuvarande status: ${importRow.status}).`) } let fiscalPeriodName: string | null = null if (importRow.fiscal_period_id) { const { data: period } = await supabase .from('fiscal_periods') .select('name, is_closed, locked_at') .eq('id', importRow.fiscal_period_id) .eq('company_id', companyId) .maybeSingle() if (period?.is_closed || period?.locked_at) { throw new Error( `Räkenskapsåret "${period.name ?? 'okänt'}" är låst eller stängt. ` + `Öppna perioden innan du ångrar importen.`, ) } fiscalPeriodName = (period as { name?: string } | null)?.name ?? null } return stagePendingOperation(supabase, companyId, userId, 'undo_sie_import', `Ångra SIE-import: ${importRow.filename}`, { import_id: importId }, { import: { id: importRow.id, filename: importRow.filename, fiscal_year: { start: importRow.fiscal_year_start, end: importRow.fiscal_year_end }, fiscal_period_name: fiscalPeriodName, transactions_count: importRow.transactions_count ?? 0, has_opening_balance_entry: Boolean(importRow.opening_balance_entry_id), imported_at: importRow.imported_at, }, reason: reason ?? null, will: 'hard-delete the import\'s journal entries (transactions + opening balance), detach user-attached documents, reset voucher_sequences, and mark the sie_imports row as \'undone\' so the file can be re-imported', }, actor, { description: 'After commit, re-stage the SIE import with corrected mappings via gnubok_import_sie.', tool: 'gnubok_import_sie', }, ) }, }, // ── Phase 4: arbitrary-line bookkeeping primitives ─────────────── { name: 'gnubok_create_voucher', title: 'Create Manual Voucher (Verifikation)', description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Lines accept dimensions bags {sie_dim_no: code or name}. Pass inbox_item_id to book a kvitto direct. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, properties: { entry_date: { type: 'string', description: 'Voucher date (YYYY-MM-DD)' }, description: { type: 'string', description: 'Verifikationstext (required, min 1 char)' }, fiscal_period_id: { type: 'string', description: 'UUID of fiscal period. If omitted, resolved from entry_date.' }, voucher_series: { type: 'string', description: 'Single letter A-Z. Defaults to A.' }, notes: { type: 'string', description: 'Internal notes (max 2000 chars): visible on the verifikation but not on reports.' }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, applied to every line not setting the key itself. Unknown values are rejected: never auto-created.', }, is_opening_balance: { type: 'boolean', description: 'Set true ONLY for a migrated ingående balans (IB). Marks the entry source_type=opening_balance so bank reconciliation excludes it from period movement. Requires every line to be a balance-sheet account (class 1/2) and entry_date = fiscal period start, else rejected. Defaults false.' }, inbox_item_id: { type: 'string', description: 'Optional inbox item UUID to book directly. On confirm, the inbox item is linked to the new verifikat and its OCR document is attached to the journal entry. Fails if the inbox item is already booked (as voucher) or converted (to supplier invoice).' }, lines: { type: 'array', description: 'At least 2 balanced lines. sum(debit_amount) === sum(credit_amount), both > 0.', items: { type: 'object', properties: { account_number: { type: 'string', description: '4-digit BAS account number, e.g. "1010"' }, debit_amount: { type: 'number', description: 'Debit amount in SEK (≥ 0)' }, credit_amount: { type: 'number', description: 'Credit amount in SEK (≥ 0)' }, line_description: { type: 'string' }, currency: { type: 'string', description: 'ISO 4217, defaults to SEK' }, amount_in_currency: { type: 'number', description: 'Original amount if currency is not SEK' }, exchange_rate: { type: 'number' }, tax_code: { type: 'string', description: 'Free-text tag: does NOT drive momsdeklaration ruta mapping. The BAS account number is what determines which ruta the line lands in (e.g. 2641 → ruta 48, 2614 → ruta 30). Pick the correct account first.' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"1":"KS01","6":"P001"}. Names resolve against the registry (high-confidence only, echoed). Wins per key over default_dimensions and cost_center/project.', }, cost_center: { type: 'string', description: 'DEPRECATED alias for dimensions["1"].' }, project: { type: 'string', description: 'DEPRECATED alias for dimensions["6"].' }, }, required: ['account_number'], }, }, }, required: ['entry_date', 'description', 'lines'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const entryDate = args.entry_date as string // Normalize like line_description (and gnubok_create_transactions): coerce // to string and trim, so a non-string or whitespace-only description is // caught by the guard below instead of slipping into the preview/voucher. const description = String(args.description ?? '').trim() const rawLines = args.lines as Array> | undefined if (!entryDate || !description || !Array.isArray(rawLines) || rawLines.length < 2) { throw new Error('entry_date, description, and at least two lines are required') } // Normalize so validateBalance + preview see consistent numeric types. const lines = rawLines.map((l, i) => ({ account_number: String(l.account_number ?? ''), debit_amount: Number(l.debit_amount) || 0, credit_amount: Number(l.credit_amount) || 0, line_description: l.line_description ? String(l.line_description) : undefined, currency: l.currency ? String(l.currency) : undefined, amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined, exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined, tax_code: l.tax_code ? String(l.tax_code) : undefined, dimensions: parseDimensionsArg(l.dimensions, `lines[${i}].dimensions`), cost_center: l.cost_center ? String(l.cost_center) : undefined, project: l.project ? String(l.project) : undefined, })) // Pre-flight: catch unbalanced lines before staging so the agent gets a // tight feedback loop instead of a rejected pending_operation later. const balance = validateBalance(lines) if (!balance.valid) { throw new Error( `Lines are not balanced: debits ${balance.totalDebit} SEK, credits ${balance.totalCredit} SEK. ` + 'Both must be positive and equal.' ) } // Resolve-don't-select: merge voucher-level default_dimensions under each // line's own bag/aliases, then resolve codes AND natural-language names // against the registry in ONE pass (zero queries when nothing is tagged; // free-text passthrough while dimensions_enabled is off). Non-exact // resolutions are echoed in the preview so the approver and the agent // both see what "Villa Almgren tak" actually attached to. const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') const { bags: resolvedBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, lines.map((l) => mergeLineDimensions(l, defaultDimensions)), ) for (const [i, line] of lines.entries()) { line.dimensions = resolvedBags[i] } // Resolve fiscal period. Two paths: // 1. Caller supplied fiscal_period_id → verify it exists and is open. // 2. Omitted → look up the open period covering entry_date. // Both paths converge on a Swedish-language error if no valid open // period is available. (NOTE: the executor re-checks period_lock at // commit time: this staging gate is advisory and exists for UX, the // commit-time guard is the authoritative one. Don't remove it as // "redundant".) let fiscalPeriodId = (args.fiscal_period_id as string | undefined) ?? null if (fiscalPeriodId) { const { data: period, error: periodErr } = await supabase .from('fiscal_periods') .select('id, is_closed, period_start, period_end, name') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .maybeSingle() if (periodErr || !period) { throw new Error(`Fiscal period ${fiscalPeriodId} not found for this company.`) } if (period.is_closed) { throw new Error( `Räkenskapsperioden "${period.name ?? fiscalPeriodId}" är låst. ` + 'Lås upp perioden, eller välj en öppen period.' ) } // Defense in depth: also verify the supplied period actually covers // entry_date so the engine's EntryDateOutsideFiscalPeriodError surfaces // as a Swedish message rather than a generic engine error. if (entryDate < period.period_start || entryDate > period.period_end) { throw new Error( `Datumet ${entryDate} ligger utanför "${period.name ?? 'perioden'}" (${period.period_start}-${period.period_end}).` ) } } else { fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate) } if (!fiscalPeriodId) { throw new Error(`No open fiscal period covers ${entryDate}. Open a period or pick a different date.`) } // Resolve account names for the preview so the approver reads // "1010 Balanserade utgifter / 2440 Leverantörsskulder" rather than // bare numbers. Also gate: refuse to stage when any line references an // unknown or inactive account so the approver isn't shown a voucher // that would fail at commit time anyway. const accountNumbers = [...new Set(lines.map((l) => l.account_number))] const { data: accounts } = await supabase .from('chart_of_accounts') .select('account_number, account_name, is_active') .eq('company_id', companyId) .in('account_number', accountNumbers) const accountInfo = new Map() for (const a of accounts || []) { accountInfo.set(a.account_number as string, { name: (a.account_name as string) ?? '', active: Boolean(a.is_active), }) } const unknownAccounts = accountNumbers.filter((n) => !accountInfo.has(n)) const inactiveAccounts = accountNumbers.filter( (n) => accountInfo.has(n) && !accountInfo.get(n)!.active, ) if (unknownAccounts.length > 0 || inactiveAccounts.length > 0) { const parts: string[] = [] if (unknownAccounts.length > 0) { parts.push(`saknas i kontoplanen: ${unknownAccounts.join(', ')}`) } if (inactiveAccounts.length > 0) { parts.push(`inaktiva: ${inactiveAccounts.join(', ')}`) } throw new Error( `Kan inte skapa verifikation. Konton ${parts.join('; ')}. ` + 'Aktivera dem i kontoplanen eller välj andra konton.' ) } const previewLines = lines.map((l) => ({ account_number: l.account_number, account_name: accountInfo.get(l.account_number)?.name ?? null, debit_amount: l.debit_amount, credit_amount: l.credit_amount, line_description: l.line_description ?? null, dimensions: l.dimensions ?? null, })) // Optional inbox-direct booking. Validate at staging so the agent gets a // tight rejection signal: once staged, an already-booked inbox item // would only surface at commit time with a generic 409. The executor // re-checks idempotently via UNIQUE constraint on // invoice_inbox_items.created_journal_entry_id. const inboxItemId = (args.inbox_item_id as string | undefined) ?? null let inboxDocumentId: string | null = null if (inboxItemId) { const { data: inbox, error: inboxErr } = await supabase .from('invoice_inbox_items') .select('id, document_id, created_journal_entry_id, created_supplier_invoice_id') .eq('id', inboxItemId) .eq('company_id', companyId) .single() if (inboxErr || !inbox) { throw new Error(`Inbox item ${inboxItemId} not found for this company.`) } if (inbox.created_journal_entry_id) { throw new Error( `Inbox item is already booked as journal entry ${inbox.created_journal_entry_id}. ` + 'Use gnubok_correct_entry or gnubok_reverse_entry if it needs to be changed.' ) } if (inbox.created_supplier_invoice_id) { throw new Error( `Inbox item is already converted to supplier invoice ${inbox.created_supplier_invoice_id}. ` + 'Cancel that path before booking it as a verifikat.' ) } inboxDocumentId = (inbox.document_id as string | null) ?? null } // NOTE: source_type is intentionally NOT included in the staged params. // The executor derives it: 'opening_balance' when the typed // is_opening_balance flag is set AND the executor re-validates the entry // genuinely looks like an IB (all class-1/2 lines, dated on the period // start); otherwise 'manual'. We never accept a raw source_type string: // a tampered or future direct-staged pending_operations row can't // misrepresent the entry's origin, only assert "this is an IB" via a // boolean the executor independently verifies. const isOpeningBalance = args.is_opening_balance === true return stagePendingOperation(supabase, companyId, userId, 'create_voucher', `${isOpeningBalance ? 'Ingående balans' : 'Manuell verifikation'}: ${description}`, { entry_date: entryDate, description, fiscal_period_id: fiscalPeriodId, voucher_series: (args.voucher_series as string) || undefined, notes: (args.notes as string) || undefined, is_opening_balance: isOpeningBalance, inbox_item_id: inboxItemId, document_id: inboxDocumentId, lines, }, { entry_date: entryDate, description, fiscal_period_id: fiscalPeriodId, voucher_series: (args.voucher_series as string) || 'A', total_debit: balance.totalDebit, total_credit: balance.totalCredit, line_count: lines.length, lines: previewLines, // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), inbox_item_id: inboxItemId, document_attached: Boolean(inboxDocumentId), will: inboxItemId ? 'create a posted journal entry with a fresh sequential voucher number, link the inbox item to it, and attach the OCR document to the verifikat' : 'create a posted journal entry with a fresh sequential voucher number', }, actor, { description: 'After commit, confirm the new verifikation lands on the right accounts with gnubok_get_general_ledger or gnubok_query_journal.', tool: 'gnubok_query_journal', }, { dateForPeriodCheck: entryDate }, ) }, }, { name: 'gnubok_correct_entry', title: 'Correct Posted Entry (Rättelse)', description: 'Stage a rättelse for a posted verifikation per BFL 5 kap 5§: storno + corrected entry in the original period (never in-place edit). Use for partial fixes like 2641 → 2614/2645; lines accept dimensions bags. Account drives ruta. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, properties: { entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, applied to every replacement line not setting the key itself. Unknown values are rejected: never auto-created.', }, lines: { type: 'array', description: 'Replacement lines (≥ 2, balanced). Use the same accounts as the original where unchanged.', items: { type: 'object', properties: { account_number: { type: 'string' }, debit_amount: { type: 'number' }, credit_amount: { type: 'number' }, line_description: { type: 'string' }, currency: { type: 'string' }, amount_in_currency: { type: 'number' }, exchange_rate: { type: 'number' }, tax_code: { type: 'string', description: 'Free-text tag: does NOT drive momsdeklaration ruta. Pick the correct BAS account first.' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dimension tags {sie_dim_no: kod eller namn}. Names resolve against the registry (high-confidence only, echoed). Wins per key over default_dimensions and cost_center/project.', }, cost_center: { type: 'string', description: 'DEPRECATED alias for dimensions["1"].' }, project: { type: 'string', description: 'DEPRECATED alias for dimensions["6"].' }, }, required: ['account_number'], }, }, }, required: ['entry_id', 'lines'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const entryRef = args.entry_id as string const rawLines = args.lines as Array> | undefined if (!entryRef || !Array.isArray(rawLines) || rawLines.length < 2) { throw new Error('entry_id and at least two lines are required') } const lines = rawLines.map((l, i) => ({ account_number: String(l.account_number ?? ''), debit_amount: Number(l.debit_amount) || 0, credit_amount: Number(l.credit_amount) || 0, line_description: l.line_description ? String(l.line_description) : undefined, currency: l.currency ? String(l.currency) : undefined, amount_in_currency: l.amount_in_currency !== undefined ? Number(l.amount_in_currency) : undefined, exchange_rate: l.exchange_rate !== undefined ? Number(l.exchange_rate) : undefined, tax_code: l.tax_code ? String(l.tax_code) : undefined, dimensions: parseDimensionsArg(l.dimensions, `lines[${i}].dimensions`), cost_center: l.cost_center ? String(l.cost_center) : undefined, project: l.project ? String(l.project) : undefined, })) const balance = validateBalance(lines) if (!balance.valid) { throw new Error( `Correction lines not balanced: debits ${balance.totalDebit}, credits ${balance.totalCredit}. ` + 'Both must be positive and equal.' ) } // Resolve-don't-select: same one-pass registry resolution as // gnubok_create_voucher (codes AND names; unknown/archived/ambiguous // values reject with candidates; nothing is ever auto-created). const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') const { bags: resolvedBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, lines.map((l) => mergeLineDimensions(l, defaultDimensions)), ) for (const [i, line] of lines.entries()) { line.dimensions = resolvedBags[i] } const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef) // Pre-flight: the executor checks again, but failing fast here gives the // agent a clearer error message than waiting until commit-time. // The Supabase types don't infer through `fiscal_periods!inner(...)`, // so we type the row shape manually rather than fight the generics. type OriginalRow = { id: string status: string entry_date: string description: string voucher_number: number voucher_series: string fiscal_period_id: string fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null lines: Array<{ account_number: string debit_amount: number | string credit_amount: number | string line_description: string | null currency: string | null amount_in_currency: number | string | null exchange_rate: number | string | null tax_code: string | null dimensions: Record | null cost_center: string | null project: string | null }> | null } const { data, error: origErr } = await supabase .from('journal_entries') .select( 'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' + 'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), ' + 'lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description, currency, amount_in_currency, exchange_rate, tax_code, dimensions, cost_center, project)' ) .eq('id', entryId) .eq('company_id', companyId) .maybeSingle() const original = data as OriginalRow | null if (origErr) { throw new Error(`Database error looking up journal entry ${entryId}: ${origErr.message}`) } if (!original) { throw new Error( `Journal entry not found: id=${entryId}. ` + `If this UUID came from an earlier tool result, re-fetch via gnubok_query_journal: ` + `UUIDs are frequently hallucinated when reused across turns. You can also pass a voucher ref like "A-113".` ) } if (original.status !== 'posted') { throw new Error(`Only posted entries can be corrected. Current status: ${original.status}.`) } const periodInfo = Array.isArray(original.fiscal_periods) ? original.fiscal_periods[0] : original.fiscal_periods if (periodInfo?.is_closed || periodInfo?.locked_at) { throw new Error( `Fiscal period "${periodInfo.name ?? 'okänd'}" is locked or closed. Unlock the period, or use omprövning for already-filed VAT.` ) } const originalLines = original.lines || [] return stagePendingOperation(supabase, companyId, userId, 'correct_entry', `Rättelse: V${original.voucher_series}${original.voucher_number} - ${original.description}`, { entry_id: entryId, lines, }, { original: { entry_id: entryId, voucher: `${original.voucher_series}${original.voucher_number}`, entry_date: original.entry_date, description: original.description, lines: originalLines.map((l) => ({ account_number: l.account_number, debit_amount: Number(l.debit_amount), credit_amount: Number(l.credit_amount), line_description: l.line_description, currency: l.currency, amount_in_currency: l.amount_in_currency != null ? Number(l.amount_in_currency) : null, exchange_rate: l.exchange_rate != null ? Number(l.exchange_rate) : null, tax_code: l.tax_code, dimensions: l.dimensions, cost_center: l.cost_center, project: l.project, })), }, correction: { total_debit: balance.totalDebit, total_credit: balance.totalCredit, line_count: lines.length, lines: lines.map((l) => ({ account_number: l.account_number, debit_amount: l.debit_amount, credit_amount: l.credit_amount, line_description: l.line_description ?? null, currency: l.currency ?? null, amount_in_currency: l.amount_in_currency ?? null, exchange_rate: l.exchange_rate ?? null, tax_code: l.tax_code ?? null, dimensions: l.dimensions ?? null, cost_center: l.cost_center ?? null, project: l.project ?? null, })), }, ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), will: 'post a storno that mirrors the original, then post a new corrected entry, then mark the original as reversed (BFL 5 kap 5§)', }, actor, { description: 'After commit, the original is marked reversed and a corrected verifikation lands in its place. Confirm both with gnubok_query_journal.', tool: 'gnubok_query_journal', }, { dateForPeriodCheck: original.entry_date }, ) }, }, { name: 'gnubok_reverse_journal_entry', title: 'Reverse Journal Entry (Storno)', description: 'Stage a storno: inverts debits/credits, original stays visible (BFL 5 kap). Only when it should never have been booked (duplicate, ghost, test). Booked wrong → gnubok_correct_entry; refund → gnubok_credit_invoice. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, properties: { entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' }, reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to today (Swedish timezone). Period attribution always follows the original entry, regardless of this date.' }, reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason: shown in pending_operations review. Not stored on the storno itself. Max 500 chars.' }, }, required: ['entry_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const entryRef = args.entry_id as string const reversalDate = typeof args.reversal_date === 'string' ? args.reversal_date : undefined const reason = typeof args.reason === 'string' ? args.reason : undefined if (!entryRef) { throw new Error('entry_id is required') } // Belt-and-braces runtime check: inputSchema declares the pattern, but the // MCP dispatcher does not always enforce it: validate again here so a // malformed date never reaches the pending_operations payload. if (reversalDate !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(reversalDate)) { throw new Error('reversal_date must be ISO yyyy-MM-dd') } if (reason !== undefined && reason.length > 500) { throw new Error('reason must be 500 characters or fewer') } const entryId = await resolveJournalEntryRef(supabase, companyId, entryRef) // Pre-flight mirrors commitReverseEntry: posted + period not closed/locked. // Failing fast gives a clearer Swedish error than waiting until commit-time. // Both is_closed and locked_at are checked so the staging-time signal // matches the commit-time gate; without locked_at, an agent could see // staged:true with period_status:locked and only discover the rejection // at commit time. type OriginalRow = { id: string status: string entry_date: string description: string voucher_number: number voucher_series: string fiscal_period_id: string fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null lines: Array<{ account_number: string debit_amount: number | string credit_amount: number | string line_description: string | null }> | null } const { data, error: origErr } = await supabase .from('journal_entries') .select( 'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' + 'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description)' ) .eq('id', entryId) .eq('company_id', companyId) .maybeSingle() const original = data as OriginalRow | null if (origErr) { throw new Error(`Database error looking up journal entry ${entryId}: ${origErr.message}`) } if (!original) { throw new Error( `Journal entry not found: id=${entryId}. ` + `If this UUID came from an earlier tool result, re-fetch via gnubok_query_journal: ` + `UUIDs are frequently hallucinated when reused across turns. You can also pass a voucher ref like "A-113".` ) } if (original.status !== 'posted') { throw new Error(`Only posted entries can be reversed. Current status: ${original.status}.`) } const periodInfo = Array.isArray(original.fiscal_periods) ? original.fiscal_periods[0] : original.fiscal_periods if (periodInfo?.is_closed || periodInfo?.locked_at) { throw new Error( `Fiscal period "${periodInfo.name ?? 'okänd'}" is locked or closed. Unlock the period, or use omprövning for already-filed VAT.` ) } const originalLines = original.lines || [] const reversedPreviewLines = originalLines.map((l) => ({ account_number: l.account_number, debit_amount: Number(l.credit_amount), credit_amount: Number(l.debit_amount), line_description: `Reversal: ${l.line_description ?? ''}`, })) // If the original touches output/input VAT accounts (2610-2670), a storno // is correct ONLY if the moms period covering entry_date has not yet been // filed with Skatteverket. For filed periods the legal path is an // omprövning (rättelse-omprövning per ML 2023:200, SFL 22 kap). Accounted // doesn't track per-VAT-period filing status today, so we surface a // soft warning rather than block: the human approver decides. const vatAccounts = originalLines .map((l) => l.account_number) .filter((acc) => /^26[1-7]\d$/.test(acc)) const vatWarning = vatAccounts.length > 0 ? `Original innehåller momskonton (${[...new Set(vatAccounts)].join(', ')}). Om momsperioden är inlämnad till Skatteverket krävs omprövning (ML 2023:200): storno räcker inte. Bekräfta att perioden inte är inlämnad innan godkännande.` : null return stagePendingOperation(supabase, companyId, userId, 'reverse_entry', `Makulering: V${original.voucher_series}${original.voucher_number} - ${original.description}`, { entry_id: entryId, reversal_date: reversalDate, }, { original: { entry_id: entryId, voucher: `${original.voucher_series}${original.voucher_number}`, entry_date: original.entry_date, description: original.description, lines: originalLines.map((l) => ({ account_number: l.account_number, debit_amount: Number(l.debit_amount), credit_amount: Number(l.credit_amount), line_description: l.line_description, })), }, reversal: { entry_date: reversalDate ?? null, fiscal_period_id: original.fiscal_period_id, line_count: reversedPreviewLines.length, lines: reversedPreviewLines, }, reason: reason ?? null, ...(vatWarning ? { warnings: [vatWarning] } : {}), will: 'post a storno that mirrors the original with debits and credits swapped, link via reverses_id, and leave the original visible (BFL 5 kap, makulering)', }, actor, { description: 'After commit, the storno is posted and the original stays visible. Confirm with gnubok_query_journal.', tool: 'gnubok_query_journal', }, { dateForPeriodCheck: original.entry_date }, ) }, }, // ─── Phase 4-7: bokslut wizard surfaces exposed to agents ─────────── { name: 'gnubok_propose_dispositioner', title: 'Propose Year-End Dispositioner', description: 'Read-only proposal of bokslutsdispositioner for a fiscal period: periodiseringsfond (avsättning + obligatorisk återföring), överavskrivningar, SLP, bolagsskatt. No dedicated MCP poster: stage entries via gnubok_create_voucher (web bokslut UI) before gnubok_run_year_end.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, // Output is the same DispositionsProposal shape returned by GET // /bokslutsdispositioner: surface as a permissive object so the // strict-schema test passes without duplicating the type tree here. outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { buildDispositionsProposal } = await import('@/lib/bokslut/dispositions-proposal-builder') return buildDispositionsProposal(supabase, companyId, fiscalPeriodId) }, }, { name: 'gnubok_propose_accruals', title: 'Propose Accruals (Periodiseringar)', description: 'Read-only proposal of periodiseringar (förutbetalda/upplupna kostnader); currently surfaces the vacation-liability change. No dedicated MCP poster: stage accrual entries via gnubok_create_voucher (or the web accruals form).', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { buildAccrualsProposal } = await import('@/lib/bokslut/accruals/accrual-detector') return buildAccrualsProposal(supabase, companyId, fiscalPeriodId) }, }, { name: 'gnubok_list_accrual_schedules', title: 'List Periodiseringar', description: 'Löpande periodiseringar (17xx/29xx): monthly installments, dissolved and remaining amounts.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['active', 'completed', 'cancelled', 'all'], description: "Default 'active'.", }, }, }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const status = (args.status as string) || 'active' let query = supabase .from('accrual_schedules') .select('*, installments:accrual_schedule_installments(*)') .eq('company_id', companyId) .order('created_at', { ascending: false }) if (status !== 'all') query = query.eq('status', status) const { data, error } = await query if (error) throw new Error(error.message) type InstallmentRow = { period_month: string; amount: number; status: string } const schedules = ((data ?? []) as Array>).map((schedule) => { const installments = ([...((schedule.installments as InstallmentRow[]) ?? [])]).sort( (a, b) => a.period_month.localeCompare(b.period_month), ) const dissolved = sumOre( installments.filter((i) => i.status === 'posted').map((i) => Number(i.amount)), ) const total = Number(schedule.total_amount) return { ...schedule, installments, dissolved_amount: dissolved, remaining_amount: schedule.status === 'cancelled' ? 0 : roundOre(total - dissolved), } }) return { schedules, count: schedules.length } }, }, { name: 'gnubok_propose_annual_depreciation', title: 'Propose Annual Depreciation (Avskrivning)', description: 'Read-only per-asset planenlig avskrivning proposal for a fiscal period. Reads the asset register and existing depreciation schedules. Call before staging the post.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { proposeAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine') return proposeAnnualPostings(supabase, companyId, fiscalPeriodId) }, }, { name: 'gnubok_post_annual_depreciation', title: 'Post Annual Depreciation (Avskrivning)', description: 'Stage planenlig avskrivning posts: one journal entry per asset for independent reversibility. Mid-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, asset_ids: { type: 'array', items: { type: 'string' }, description: 'Optional whitelist of asset UUIDs to post; omit to post all proposed.', }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_end, is_closed, locked_at, closing_entry_id') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found') if (period.is_closed || period.closing_entry_id || period.locked_at) { throw new Error('Period is locked or closed') } const { proposeAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine') const proposal = await proposeAnnualPostings(supabase, companyId, fiscalPeriodId) const filtered = assetIds ? proposal.items.filter((i) => assetIds.includes(i.asset.id)) : proposal.items const pending = filtered.filter((i) => !i.existingJournalEntryId) const totalAmount = pending.reduce((s, i) => s + i.amount, 0) return stagePendingOperation( supabase, companyId, userId, 'post_annual_depreciation', `Planenlig avskrivning: ${period.name}, ${pending.length} tillgång(ar), ${Math.round(totalAmount * 100) / 100} SEK`, { fiscal_period_id: fiscalPeriodId, asset_ids: assetIds }, { period_name: period.name, item_count: pending.length, total_amount: totalAmount, will: `book ${pending.length} planenlig avskrivning(ar): one journal entry per asset`, items: pending.map((i) => ({ asset_id: i.asset.id, asset_name: i.asset.name, amount: i.amount, pro_rated: i.proRated, })), }, actor, { description: 'After approval, depreciation entries are posted. Continue the year-end flow via gnubok_year_end_readiness, then gnubok_run_year_end.', tool: 'gnubok_year_end_readiness', args: { fiscal_period_id: fiscalPeriodId }, }, { dateForPeriodCheck: period.period_end }, ) }, }, { name: 'gnubok_preview_arsredovisning', title: 'Preview Annual Report (Årsredovisning)', description: 'Read-only annual report preview from the canonical model. Returns report content, eligibility, compliance blockers, and capabilities. PDF and immutable versions are available in the UI.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { buildCanonicalAnnualReport } = await import('@/lib/bokslut/arsredovisning/model') const { getAnnualReportCapabilities } = await import( '@/lib/bokslut/arsredovisning/capabilities' ) const model = await buildCanonicalAnnualReport(supabase, companyId, fiscalPeriodId, { stage: 'draft', includeIxbrl: false, }) return { schema_version: model.schema_version, generated_at: model.generated_at, report: model.report, profile: model.profile, disclosures: model.disclosures, eligibility: model.eligibility, validation: model.validation, capabilities: getAnnualReportCapabilities( model.report.accounting_framework, model.eligibility, ), } }, }, { name: 'gnubok_validate_arsredovisning', title: 'Validate Annual Report (Årsredovisning)', description: 'Read-only compliance validation for an annual report. Use draft while editing, signing before locking a version, and filing before a Bolagsverket submission.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, stage: { type: 'string', enum: ['draft', 'signing', 'filing'], description: 'Validation strictness. Default: draft', }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const stage = (args.stage as 'draft' | 'signing' | 'filing' | undefined) ?? 'draft' const { buildCanonicalAnnualReport } = await import('@/lib/bokslut/arsredovisning/model') const model = await buildCanonicalAnnualReport(supabase, companyId, fiscalPeriodId, { stage, includeIxbrl: stage === 'filing', }) return { fiscal_period_id: fiscalPeriodId, framework: model.report.accounting_framework, profile: model.profile, eligibility: model.eligibility, validation: model.validation, } }, }, { name: 'gnubok_list_arsredovisning_versions', title: 'List Annual Report Versions', description: 'Read-only list of immutable annual report versions with content hashes, taxonomy versions, and signing or filing status.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { listAnnualReportVersions } = await import( '@/lib/bokslut/arsredovisning/version-service' ) const versions = await listAnnualReportVersions(supabase, companyId, fiscalPeriodId) return { fiscal_period_id: fiscalPeriodId, versions } }, }, { name: 'gnubok_get_arsredovisning_filing_status', title: 'Get Annual Report Filing Status', description: 'Read-only filing history for a fiscal period, including uncertain upload states that must be reconciled before retrying.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data, error } = await supabase .from('arsredovisning_submissions') .select( 'id, annual_report_version_id, handling_typ, environment, status, archive_status, bolagsverket_url, error_message, uploaded_at, registered_at, created_at', ) .eq('company_id', companyId) .eq('fiscal_period_id', fiscalPeriodId) .order('created_at', { ascending: false }) .limit(50) if (error) throw new Error(`Failed to list annual report filings: ${error.message}`) const publicFields = [ 'id', 'annual_report_version_id', 'handling_typ', 'environment', 'status', 'archive_status', 'bolagsverket_url', 'error_message', 'uploaded_at', 'registered_at', 'created_at', ] as const const submissions = (data ?? []).map((submission) => Object.fromEntries( publicFields.flatMap((field) => field in submission ? [[field, submission[field]]] : [], ), ), ) return { fiscal_period_id: fiscalPeriodId, submissions } }, }, { name: 'gnubok_preview_ef_declaration', title: 'Preview EF Declaration (NE-bilaga)', description: 'Read-only EF declaration preview: egenavgifter schablonavdrag, räntefördelning, periodiseringsfond, expansionsfond. All declaration-only, never booked. Pass kapitalunderlag and prior-year amounts as inputs.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, category: { type: 'string', enum: ['full', 'pensioner', 'passive'], description: 'Egenavgifter category: defaults to "full"', }, kapitalunderlag: { type: 'number', description: 'Justerat eget kapital vid föregående års utgång (default 0)' }, prior_year_schablonavdrag: { type: 'number' }, prior_year_actual_charged: { type: 'number' }, pfond_desired_amount: { type: 'number' }, expansionsfond_existing_balance: { type: 'number' }, expansionsfond_desired_change: { type: 'number' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { computeEfDeclarationPreview } = await import('@/lib/bokslut/enskild-firma/ef-declaration-preview') return computeEfDeclarationPreview(supabase, companyId, fiscalPeriodId, { category: args.category as 'full' | 'pensioner' | 'passive' | undefined, kapitalunderlag: args.kapitalunderlag as number | undefined, priorYearSchablonavdrag: args.prior_year_schablonavdrag as number | undefined, priorYearActualCharged: args.prior_year_actual_charged as number | undefined, pfondDesiredAmount: args.pfond_desired_amount as number | undefined, expansionsfondExistingBalance: args.expansionsfond_existing_balance as number | undefined, expansionsfondDesiredChange: args.expansionsfond_desired_change as number | undefined, }) }, }, // ── Pending operations: list / approve / reject ────────────── // Mirrors the /pending web UI for agents that self-review before committing. { name: 'gnubok_list_pending_operations', title: 'List Pending Operations', description: 'List staged pending_operations. Filter by status (default pending), risk_level, or operation_type. Approve via gnubok_approve_pending_operation, discard via gnubok_reject_pending_operation. render_ui=true opens the approval widget.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['pending', 'committing', 'committed', 'rejected', 'failed_partial'], description: 'Default: pending' }, risk_level: { type: 'string', enum: ['low', 'medium', 'high'] }, operation_type: { type: 'string', description: 'Filter to a single operation_type (e.g. "create_invoice")' }, limit: { type: 'number', minimum: 1, maximum: 200, description: 'Default 50' }, offset: { type: 'number', minimum: 0, description: 'Default 0' }, render_ui: { type: 'boolean', description: 'Render the interactive approval widget (claude.ai / Desktop): approve/reject by click; the click supplies the high-risk BFL acknowledgment. Data returned either way. Default false.', }, }, required: [], }, outputSchema: paginatedSchema('operations'), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, // Renders the approval-queue widget only when the caller passes // render_ui=true (the dispatcher emits result-level _meta in that case), // keeping the tool data-only by default. uiResourceUri: 'ui://pending-operations/app.html', async execute(args, companyId, _userId, supabase) { const status = (args.status as string) ?? 'pending' const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50)) const offset = Math.max(0, (args.offset as number) ?? 0) // `params` holds the raw operation inputs (invoice line items, supplier // PII, voucher descriptions): excluded from the list response to // satisfy data-minimisation (GDPR Art. 5(1)(b)). Use preview_data for // a redacted, human-readable summary, or call the underlying entity // endpoint when the agent needs the full payload. let query = supabase .from('pending_operations') .select( 'id, operation_type, title, preview_data, status, risk_level, actor_type, actor_id, actor_label, created_at, resolved_at, result_data', { count: 'exact' } ) .eq('company_id', companyId) .eq('status', status) .order('created_at', { ascending: false }) .range(offset, offset + limit - 1) if (args.risk_level) query = query.eq('risk_level', args.risk_level as string) if (args.operation_type) query = query.eq('operation_type', args.operation_type as string) const { data, error, count } = await query if (error) throw new Error(`Failed to list pending operations: ${error.message}`) const operations = data ?? [] const totalCount = count ?? operations.length const hasMore = offset + operations.length < totalCount return { operations, count: operations.length, total_count: totalCount, has_more: hasMore, ...(hasMore ? { next_offset: offset + operations.length } : {}), } }, }, { name: 'gnubok_approve_pending_operation', title: 'Approve Pending Operation', description: "Commit a staged pending_operation the user has explicitly authorised. risk_level=high requires confirmed=true: surface the BFL 5 kap 5§ irreversibility first. The /pending web UI offers an equivalent commit path.", inputSchema: { type: 'object', additionalProperties: false, properties: { operation_id: { type: 'string', description: 'UUID of the pending_operations row to approve' }, confirmed: { type: 'boolean', description: 'Required when the operation has risk_level=high (create_voucher, correct_entry, reverse_entry, year-end, period lock/close). Acknowledges the BFL/BFNAR irreversibility implications. The web UI surfaces the same gate via an explicit warning dialog.', }, }, required: ['operation_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['committed', 'rejected', 'failed'] }, operation_id: { type: 'string' }, data: { type: 'object' }, error: { type: 'string' }, auto_rejected: { type: 'boolean' }, }, required: ['status', 'operation_id'], }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const operationId = args.operation_id as string if (!operationId) throw new Error('operation_id is required') const { data: op, error: fetchError } = await supabase .from('pending_operations') .select('*') .eq('id', operationId) .eq('company_id', companyId) .single() if (fetchError || !op) throw new Error('Pending operation not found') // High-risk operations require explicit confirmation in addition to the // standard pending_operations:approve scope. Mirrors the web-UI gate // (BFL 5 kap 5§: irreversible postings require positive acknowledgment). const operation = op as PendingOperation if (operation.risk_level === 'high' && args.confirmed !== true) { throw new Error( `Operation "${operation.operation_type}" is risk_level=high: pass confirmed=true to approve. The web UI requires the same positive acknowledgment per BFL 5 kap 5§ (irreversible postings).` ) } // Resolve the user's email so commitPendingOperation can attribute the // journal_entries.committed_by_email and any user-facing email side // effects (send_invoice cc) to the actor: matches the web-UI commit // path attribution (V8.2.1, GDPR Art. 25(1)). let userEmail: string | undefined try { const { data: userData } = await supabase.auth.admin.getUserById(userId) userEmail = userData.user?.email ?? undefined } catch (err) { log.warn('Failed to resolve user email for MCP approval', { userId, err }) } // commit_method provenance (agent_first_vision.md §8 P0-1): MCP // approvals are relayed through an agent credential: record that in // the immutable layer instead of claiming 'user_accept'. The positive // acknowledgment (confirmed=true for high risk) is agent-attested, not // a first-party human session; an auditor reading the GL can now tell // the difference (BFNAR 2013:2 kap 8 behandlingshistorik). // // ALL MCP traffic authenticates as an api_key actor: the claude.ai // OAuth connector's access_token is itself a minted gnubok_sk_ key // (app/api/mcp-oauth/token/route.ts), indistinguishable from the // bridge at this layer: so 'api_key' is the truthful value for every // path through this handler. 'agent' (also in the CHECK) is reserved // for first-party agent surfaces (e.g. in-app agent chat) once they // commit through this layer with a distinguishable actor type. // // commitMethod reaches the journal only for create_voucher ops // (pre-existing); the actor option below covers EVERY journal commit // this operation makes via the runWithActor() scope inside // commitPendingOperation, stamping journal_entries.committed_actor_* // and the audit_log COMMIT row (migration 20260619120000). const commitMethod = actor?.type === 'api_key' ? ('api_key' as const) : ('user_accept' as const) const result = await commitPendingOperation( supabase, userId, companyId, operation, { commitMethod, actor: { type: actor?.type === 'api_key' ? 'api_key' : 'user', ...(actor?.label ? { label: actor.label } : {}), }, ...(userEmail ? { userEmail } : {}), } ) // Audit the MCP-initiated approval. Failure must not break the user // flow: the side-effects have already happened. try { await appendProcessingHistory({ companyId, correlationId: operationId, aggregateType: 'System', aggregateId: operationId, eventType: 'PendingOperationApproved', payload: { operation_id: operationId, operation_type: operation.operation_type, risk_level: operation.risk_level, outcome: result.status, commit_method: commitMethod, channel: 'mcp', confirmed: args.confirmed === true, }, actor: { type: actor?.type === 'api_key' ? 'api_key' : 'user', id: actor?.id ?? userId, ...(actor?.label ? { label: actor.label } : {}), }, occurredAt: new Date(), }) } catch (auditErr) { log.warn('Failed to append PendingOperationApproved audit event', auditErr) } return { status: result.status, operation_id: operationId, ...(result.data ? { data: result.data } : {}), ...(result.error ? { error: result.error } : {}), ...(result.auto_rejected ? { auto_rejected: true } : {}), } }, }, { name: 'gnubok_reject_pending_operation', title: 'Reject Pending Operation', description: 'Reject a staged pending_operation without executing it. Status flips to rejected; no journal entries, invoices, or other side-effects created. Idempotent on already-resolved ops (returns 409).', inputSchema: { type: 'object', additionalProperties: false, properties: { operation_id: { type: 'string', description: 'UUID of the pending_operations row to reject' }, reason: { type: 'string', description: 'Optional human-readable reason recorded in result_data for the audit trail', maxLength: 500, }, }, required: ['operation_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['rejected'] }, operation_id: { type: 'string' }, }, required: ['status', 'operation_id'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const operationId = args.operation_id as string if (!operationId) throw new Error('operation_id is required') const reason = typeof args.reason === 'string' ? args.reason.slice(0, 500) : undefined const { data: op, error: fetchError } = await supabase .from('pending_operations') .select('id, status, operation_type, risk_level') .eq('id', operationId) .eq('company_id', companyId) .single() if (fetchError || !op) throw new Error('Pending operation not found') if (op.status !== 'pending') { // No auto-commit path exists (removed in 20260505190027). A non-pending // status means the op was resolved explicitly: usually the user // approved it in the Att göra / pending UI in parallel. Make that // explicit so the agent doesn't read it as a silent auto-commit. throw new Error( op.status === 'rejected' ? 'Operation already rejected.' : op.status === 'failed_partial' ? 'Operation already resolved as failed_partial: it failed after posting an ' + 'irreversible voucher. See result_data.posted_ids for what was posted and ' + 'correct it with a storno if needed.' : `Operation already ${op.status}: approved explicitly (likely via the pending UI), ` + 'not auto-committed. Reverse or correct the resulting verifikat instead.', ) } // Atomic claim: flips pending → rejected only when the row is still // pending AND in the caller's tenant (V8.3.1, CC6.3 tenant isolation). // The .eq('status', 'pending') guard makes this a CAS so a concurrent // approval cannot lose to a parallel reject. const { data: updated, error: updateError } = await supabase .from('pending_operations') .update({ status: 'rejected', resolved_at: new Date().toISOString(), result_data: { rejected_by: userId, rejected_via: actor?.type ?? 'user', ...(actor?.id ? { actor_id: actor.id } : {}), ...(reason ? { reason } : {}), }, }) .eq('id', operationId) .eq('company_id', companyId) .eq('status', 'pending') .select('id') if (updateError) throw new Error(`Failed to reject operation: ${updateError.message}`) if (!updated || updated.length === 0) { throw new Error('Operation no longer pending: another caller claimed it') } // Audit the rejection so the trail mirrors the approval path. try { await appendProcessingHistory({ companyId, correlationId: operationId, aggregateType: 'System', aggregateId: operationId, eventType: 'PendingOperationRejected', payload: { operation_id: operationId, operation_type: op.operation_type, risk_level: op.risk_level, channel: 'mcp', ...(reason ? { has_reason: true } : { has_reason: false }), }, actor: { type: actor?.type === 'api_key' ? 'api_key' : 'user', id: actor?.id ?? userId, ...(actor?.label ? { label: actor.label } : {}), }, occurredAt: new Date(), }) } catch (auditErr) { log.warn('Failed to append PendingOperationRejected audit event', auditErr) } return { status: 'rejected' as const, operation_id: operationId } }, }, // ── Bring-your-own-extraction for inbox items ──────────────── { name: 'gnubok_set_inbox_extracted_data', title: 'Set Inbox Extracted Data', description: 'Replace extracted_data on an inbox item with agent-supplied fields. Use when your pipeline parses the document better than Accounted\'s OCR. Follow with gnubok_create_supplier_invoice_from_inbox to stage.', inputSchema: { type: 'object', additionalProperties: false, properties: { inbox_item_id: { type: 'string', description: 'UUID of the invoice_inbox_items row' }, extracted_data: { type: 'object', description: 'Full InvoiceExtractionResult (supplier, invoice, lineItems, totals, vatBreakdown). lineItems.accountSuggestion accepts a BAS expense account (4xxx-7xxx); AI extractor always emits null here.', }, }, required: ['inbox_item_id', 'extracted_data'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { inbox_item_id: { type: 'string' }, matched_supplier_id: { type: ['string', 'null'] }, extracted_data: { type: 'object' }, }, required: ['inbox_item_id', 'extracted_data'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const inboxItemId = args.inbox_item_id as string if (!inboxItemId) throw new Error('inbox_item_id is required') const parsed = AgentExtractionSchema.parse(args.extracted_data) // BYO extraction: confidence 0.95 marks the result as agent-supplied // (vs 1.0 the AI extractor uses on a perfect parse) so downstream UI // can render the provenance differently (ISO 27001 A.8.12). // AgentExtractionSchema (unlike InvoiceExtractionSchema) preserves // accountSuggestion so agents can pin a BAS cost account per line. const extracted = { ...parsed, confidence: 0.95 } const { data: item, error: fetchError } = await supabase .from('invoice_inbox_items') .select('id, company_id, created_supplier_invoice_id') .eq('id', inboxItemId) .eq('company_id', companyId) .maybeSingle() if (fetchError) throw new Error(`Failed to fetch inbox item: ${fetchError.message}`) if (!item) throw new Error('Inbox item not found') // Explicit defense-in-depth tenant check (V4.5.1) alongside the .eq() // filter on the SELECT: surfaces a tampered service-role query // before it reaches the UPDATE. if (item.company_id !== companyId) { throw new Error('Inbox item belongs to a different company') } if (item.created_supplier_invoice_id) { throw new Error('Inbox item is already linked to a supplier invoice and cannot be modified') } // Re-run supplier match so agent-supplied fields trigger the same // auto-link the AI path does (org-nr → name, ILIKE). let matchedSupplierId: string | null = null if (extracted.supplier.orgNumber) { const { data: s } = await supabase .from('suppliers') .select('id') .eq('company_id', companyId) .eq('org_number', extracted.supplier.orgNumber) .limit(1) .maybeSingle() if (s) matchedSupplierId = s.id } if (!matchedSupplierId && extracted.supplier.name) { const { data: s } = await supabase .from('suppliers') .select('id') .eq('company_id', companyId) .ilike('name', extracted.supplier.name) .limit(1) .maybeSingle() if (s) matchedSupplierId = s.id } const { error: updateError } = await supabase .from('invoice_inbox_items') .update({ extracted_data: extracted as unknown as Record, matched_supplier_id: matchedSupplierId, }) .eq('id', inboxItemId) .eq('company_id', companyId) if (updateError) throw new Error(`Failed to update inbox item: ${updateError.message}`) // Audit the BYO override so financial-data provenance is traceable // (GDPR Art. 5(1)(f), SOC 2 CC9.2). Failure must not block the user // flow: the override has already landed in the DB. try { await appendProcessingHistory({ companyId, correlationId: inboxItemId, aggregateType: 'Document', aggregateId: inboxItemId, eventType: 'DocumentExtractionOverridden', payload: { inbox_item_id: inboxItemId, channel: 'mcp', has_supplier_org_number: extracted.supplier.orgNumber != null, has_invoice_number: extracted.invoice.invoiceNumber != null, extracted_total: extracted.totals.total, matched_supplier_id: matchedSupplierId, }, actor: { type: actor?.type === 'api_key' ? 'api_key' : 'user', id: actor?.id ?? userId, ...(actor?.label ? { label: actor.label } : {}), }, occurredAt: new Date(), }) } catch (auditErr) { log.warn('Failed to append DocumentExtractionOverridden audit event', auditErr) } return { inbox_item_id: inboxItemId, matched_supplier_id: matchedSupplierId, extracted_data: extracted as unknown as Record, } }, }, // ── Recurring invoice schedules ────────────────────────────── { name: 'gnubok_list_recurring_schedules', title: 'List Recurring Invoice Schedules', description: "List the company's recurring invoice schedules: monthly templates that auto-create customer invoices on day_of_month (clamps to the last day in shorter months) at send_hour (a whole hour in Europe/Stockholm time). Shows status, auto_send and next_run_date.", inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['active', 'paused'], description: 'Filter by schedule status', }, limit: { type: 'number', description: 'Max results (default 50, max 100)' }, offset: { type: 'integer', minimum: 0, description: 'Number of results to skip for pagination (default 0)' }, }, }, outputSchema: paginatedSchema('schedules', { type: 'object', properties: { recurring_schedule_id: { type: 'string' }, name: { type: 'string' }, status: { type: 'string', enum: ['active', 'paused'] }, customer_id: { type: 'string' }, customer_name: { type: ['string', 'null'] }, day_of_month: { type: 'number', description: '1-31; clamps to the last day in shorter months' }, send_hour: { type: 'number', description: 'Whole hour 0-23 in Europe/Stockholm time' }, payment_terms_days: { type: 'number' }, currency: { type: 'string' }, auto_send: { type: 'boolean' }, next_run_date: { type: 'string' }, last_run_at: { type: ['string', 'null'] }, last_invoice_id: { type: ['string', 'null'], description: 'Most recently generated invoice' }, last_run_warning: { type: ['string', 'null'] }, generated_count: { type: 'number' }, monthly_total_excl_vat: { type: 'number' }, default_dimensions: { type: 'object', description: 'Dims bag {sie_dim_no: code} copied onto every generated invoice', }, items: { type: 'array', items: { type: 'object', properties: { description: { type: 'string' }, quantity: { type: 'number' }, unit: { type: 'string' }, unit_price: { type: 'number' }, vat_rate: { type: ['number', 'null'], description: 'null = customer default at spawn time' }, dimensions: { type: 'object', description: 'Per-item dims bag; wins per key over default_dimensions' }, }, }, }, }, }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, catalogVisibility: 'search', async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100) const offset = Math.max(0, Math.floor(Number(args.offset) || 0)) const status = args.status as string | undefined let query = supabase .from('recurring_invoice_schedules') .select( 'id, name, status, customer_id, day_of_month, send_hour, payment_terms_days, currency, auto_send, default_dimensions, next_run_date, last_run_at, last_invoice_id, last_run_warning, generated_count, customer:customers(name), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, dimensions, sort_order)', { count: 'exact' }, ) .eq('company_id', companyId) if (status) { query = query.eq('status', status) } const { data, error, count } = await query .order('created_at', { ascending: false }) .order('id', { ascending: false }) .range(offset, offset + limit) if (error) throw new Error(`Database error: ${error.message}`) const rows = data ?? [] const schedules = rows.slice(0, limit).map((row: Record) => { const items = ((row.items as Array>) ?? []) .slice() .sort((a, b) => Number(a.sort_order) - Number(b.sort_order)) .map((it) => ({ description: it.description, quantity: it.quantity, unit: it.unit, unit_price: it.unit_price, vat_rate: it.vat_rate ?? null, dimensions: it.dimensions ?? {}, })) const monthlyTotalExclVat = Math.round(items.reduce((sum, it) => sum + Number(it.quantity) * Number(it.unit_price), 0) * 100) / 100 return { recurring_schedule_id: row.id, name: row.name, status: row.status, customer_id: row.customer_id, customer_name: (row.customer as Record | null)?.name ?? null, day_of_month: row.day_of_month, send_hour: row.send_hour, payment_terms_days: row.payment_terms_days, currency: row.currency, auto_send: row.auto_send, next_run_date: row.next_run_date, last_run_at: row.last_run_at ?? null, last_invoice_id: row.last_invoice_id ?? null, last_run_warning: row.last_run_warning ?? null, generated_count: row.generated_count, monthly_total_excl_vat: monthlyTotalExclVat, default_dimensions: row.default_dimensions ?? {}, items, } }) const hasMore = count == null ? rows.length > limit : offset + schedules.length < count const total = count ?? offset + schedules.length + (hasMore ? 1 : 0) return { schedules, count: schedules.length, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + schedules.length } : {}), } }, }, { name: 'gnubok_create_recurring_schedule', title: 'Create Recurring Invoice Schedule', description: 'Stage a new recurring invoice schedule: a monthly template that creates a customer invoice on day_of_month (clamps to the last day in shorter months) at send_hour (a whole hour in Europe/Stockholm time). auto_send defaults false; true emails each invoice without new approval.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { customer_id: { type: 'string', description: 'Customer UUID from gnubok_list_customers.' }, name: { type: 'string', minLength: 1, maxLength: 200, description: 'Internal schedule name (not printed on the invoice).' }, day_of_month: { type: 'integer', minimum: 1, maximum: 31, description: 'Day of month the invoice is created. 29-31 clamp to the last day in shorter months; the stored day is kept for longer months.', }, send_hour: { type: 'integer', minimum: 0, maximum: 23, description: 'Whole hour (0-23) in Europe/Stockholm time at which the schedule runs. Default 8.', }, payment_terms_days: { type: 'integer', minimum: 0, maximum: 90, description: 'due_date = invoice_date + terms. Default 30.' }, currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'], description: 'Default SEK.' }, your_reference: { type: 'string' }, our_reference: { type: 'string' }, notes: { type: 'string' }, auto_send: { type: 'boolean', description: 'Default false: invoices are created as drafts for manual review. true emails every generated invoice to the customer with no further approval; requires the customer to have an email address.', }, start_date: { type: 'string', description: 'YYYY-MM-DD first run date. Omit to run on the next occurrence of day_of_month.' }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag keyed by SIE dim no, value = code OR name, e.g. {"6":"P001"}. Copied onto every generated invoice. Unknown values rejected: never auto-created.', }, items: { type: 'array', minItems: 1, description: 'Template lines copied onto every generated invoice.', items: { type: 'object', properties: { description: { type: 'string' }, quantity: { type: 'number' }, unit: { type: 'string', description: 'st, tim, dag, mån. Default st.' }, unit_price: { type: 'number', description: 'Price per unit excl. VAT.' }, vat_rate: { type: ['number', 'null'], enum: [0, 6, 12, 25, null], description: 'Omit or null to use the customer default VAT rate at spawn time.', }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.', }, }, required: ['description', 'quantity', 'unit_price'], }, }, dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' }, }, required: ['customer_id', 'name', 'day_of_month', 'items'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { // Resolve-don't-select: parse the schedule-level default bag + each // item's own bag, then resolve codes AND natural-language names against // the registry in ONE pass (mirrors gnubok_create_invoice). The staged // params carry only resolved codes; the cron copies them verbatim onto // every generated invoice. const rawItems = Array.isArray(args.items) ? (args.items as Array>) : [] const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, [defaultDimensions, ...rawItems.map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`))], ) const resolvedDefaultDimensions = resolvedDimBags[0] const stagedItems = rawItems.map((item, i) => { const { dimensions: _rawDimensions, ...rest } = item const bag = resolvedDimBags[i + 1] return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest }) const candidate: Record = {} for (const key of [ 'customer_id', 'name', 'day_of_month', 'send_hour', 'payment_terms_days', 'currency', 'your_reference', 'our_reference', 'notes', 'auto_send', 'start_date', ]) { if (args[key] !== undefined) candidate[key] = args[key] } if (args.items !== undefined) { // Non-array garbage passes through verbatim so the schema error below // names the real problem instead of a synthetic empty list. candidate.items = Array.isArray(args.items) ? stagedItems : args.items } if (resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0) { candidate.default_dimensions = resolvedDefaultDimensions } const parsed = CreateRecurringScheduleParamsSchema.safeParse(candidate) if (!parsed.success) { const issue = parsed.error.issues[0] throw new Error(`Invalid recurring schedule: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`) } const params = parsed.data const { data: customer, error } = await supabase .from('customers') .select('id, name, email') .eq('id', params.customer_id) .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!customer) throw new Error('Customer not found. Use gnubok_list_customers to find IDs.') if (params.auto_send && !customer.email) { throw new Error('Customer has no email address: auto_send requires one. Stage with auto_send=false or add an email first.') } const monthlyTotalExclVat = Math.round(params.items.reduce((sum, it) => sum + it.quantity * it.unit_price, 0) * 100) / 100 // auto_send appears explicitly in the preview: an auto-sending schedule // is recurring outbound customer email that never sees approval again, // so the human must see exactly that flag when approving. const preview = { name: params.name, customer_id: customer.id, customer_name: customer.name, day_of_month: params.day_of_month, send_hour: params.send_hour, payment_terms_days: params.payment_terms_days, currency: params.currency, auto_send: params.auto_send, projected_first_run_date: computeInitialRunDate(new Date(), params.day_of_month, params.start_date), monthly_total_excl_vat: monthlyTotalExclVat, items: params.items, ...(params.default_dimensions && Object.keys(params.default_dimensions).length > 0 ? { default_dimensions: params.default_dimensions } : {}), // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), } return stagePendingOperation(supabase, companyId, userId, 'create_recurring_schedule', `Nytt återkommande fakturaschema: ${params.name}`, params as unknown as Record, preview, actor, { description: 'Once approved, verify the schedule with gnubok_list_recurring_schedules; the hourly cron creates invoices from next_run_date.', tool: 'gnubok_list_recurring_schedules', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, } ) }, }, { name: 'gnubok_update_recurring_schedule', title: 'Update Recurring Invoice Schedule', description: 'Stage an update to a recurring invoice schedule (schedule_id from gnubok_list_recurring_schedules). Pause/resume via status. items replace all lines; omit to keep them. day_of_month clamps to the last day in shorter months; send_hour is a whole hour in Europe/Stockholm.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { schedule_id: { type: 'string', description: 'UUID from gnubok_list_recurring_schedules.' }, customer_id: { type: 'string', description: 'Move the schedule to another customer.' }, name: { type: 'string', minLength: 1, maxLength: 200 }, day_of_month: { type: 'integer', minimum: 1, maximum: 31, description: '1-31; clamps to the last day in shorter months. Changing it rolls next_run_date to the next future occurrence.', }, send_hour: { type: 'integer', minimum: 0, maximum: 23, description: 'Whole hour (0-23) in Europe/Stockholm time.' }, payment_terms_days: { type: 'integer', minimum: 0, maximum: 90 }, currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] }, your_reference: { type: ['string', 'null'], description: 'Null clears the field.' }, our_reference: { type: ['string', 'null'], description: 'Null clears the field.' }, notes: { type: ['string', 'null'], description: 'Null clears the field.' }, auto_send: { type: 'boolean', description: 'true emails every generated invoice with no further approval (requires customer email). false returns to draft-only.', }, status: { type: 'string', enum: ['active', 'paused'], description: 'paused stops generating invoices; active resumes. Reactivating from a stale date rolls next_run_date to the next future occurrence, never today.', }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn} copied onto every generated invoice. Replaces the whole bag; {} clears all tags. Omit to keep.', }, items: { type: 'array', minItems: 1, description: 'Replaces ALL existing template lines when provided; omit to keep the current lines unchanged.', items: { type: 'object', properties: { description: { type: 'string' }, quantity: { type: 'number' }, unit: { type: 'string', description: 'st, tim, dag, mån. Default st.' }, unit_price: { type: 'number', description: 'Price per unit excl. VAT.' }, vat_rate: { type: ['number', 'null'], enum: [0, 6, 12, 25, null], description: 'Omit or null to use the customer default VAT rate at spawn time.', }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}. Wins per key over default_dimensions.', }, }, required: ['description', 'quantity', 'unit_price'], }, }, dry_run: { type: 'boolean', description: 'Validate and preview without staging or changing data.' }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Reusing it with the same payload returns the original staged response.' }, }, required: ['schedule_id'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { // Resolve-don't-select for both the replacement default bag and any // per-item bags (mirrors gnubok_create_recurring_schedule). An explicit // {} default_dimensions passes through as the clear-all-tags update. const rawItems = Array.isArray(args.items) ? (args.items as Array>) : [] const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, [defaultDimensions, ...rawItems.map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`))], ) const stagedItems = rawItems.map((item, i) => { const { dimensions: _rawDimensions, ...rest } = item const bag = resolvedDimBags[i + 1] return bag && Object.keys(bag).length > 0 ? { ...rest, dimensions: bag } : rest }) const changes: Record = {} for (const key of [ 'customer_id', 'name', 'day_of_month', 'send_hour', 'payment_terms_days', 'currency', 'your_reference', 'our_reference', 'notes', 'auto_send', 'status', ]) { if (args[key] !== undefined) changes[key] = args[key] } if (args.default_dimensions !== undefined) { changes.default_dimensions = resolvedDimBags[0] ?? {} } if (args.items !== undefined) { // Non-array garbage passes through verbatim so the schema error below // names the real problem instead of a synthetic empty list. changes.items = Array.isArray(args.items) ? stagedItems : args.items } const parsed = UpdateRecurringScheduleParamsSchema.safeParse({ schedule_id: args.schedule_id, changes, }) if (!parsed.success) { const issue = parsed.error.issues[0] throw new Error(`Invalid schedule update: ${issue ? `${issue.path.join('.')}: ${issue.message}` : 'validation failed'}`) } const parsedChanges = parsed.data.changes const { data: current, error } = await supabase .from('recurring_invoice_schedules') .select( 'id, name, status, customer_id, day_of_month, send_hour, payment_terms_days, currency, your_reference, our_reference, notes, auto_send, default_dimensions, next_run_date, customer:customers(name, email), items:recurring_invoice_schedule_items(description, quantity, unit, unit_price, vat_rate, dimensions, sort_order)', ) .eq('id', parsed.data.schedule_id) .eq('company_id', companyId) .maybeSingle() if (error) throw new Error(`Database error: ${error.message}`) if (!current) throw new Error('Recurring schedule not found. Use gnubok_list_recurring_schedules to find IDs.') // Turning auto_send on, or moving the schedule to another customer, // requires the (target) customer to have an email when auto_send is // effectively on; otherwise every cron run degrades to a draft + // warning. Mirrors the cookie-session PATCH route's guard. const effectiveAutoSend = parsedChanges.auto_send ?? (current.auto_send as boolean) if (parsedChanges.customer_id !== undefined) { const { data: target, error: targetError } = await supabase .from('customers') .select('id, email') .eq('id', parsedChanges.customer_id) .eq('company_id', companyId) .maybeSingle() if (targetError) throw new Error(`Database error: ${targetError.message}`) if (!target) throw new Error('Customer not found. Use gnubok_list_customers to find IDs.') if (effectiveAutoSend && !target.email) { throw new Error('Customer has no email address: auto_send requires one.') } } else if (parsedChanges.auto_send === true) { const currentCustomer = current.customer as { name?: string; email?: string | null } | null if (!currentCustomer?.email) { throw new Error('Customer has no email address: auto_send requires one. Add an email to the customer first.') } } const currentItems = ((current.items as Array>) ?? []) .slice() .sort((a, b) => Number(a.sort_order) - Number(b.sort_order)) .map((it) => ({ description: it.description, quantity: it.quantity, unit: it.unit, unit_price: it.unit_price, vat_rate: it.vat_rate ?? null, dimensions: it.dimensions ?? {}, })) const currentPreview = { recurring_schedule_id: current.id, name: current.name, status: current.status, customer_id: current.customer_id, customer_name: (current.customer as { name?: string } | null)?.name ?? null, day_of_month: current.day_of_month, send_hour: current.send_hour, payment_terms_days: current.payment_terms_days, currency: current.currency, your_reference: current.your_reference ?? null, our_reference: current.our_reference ?? null, notes: current.notes ?? null, auto_send: current.auto_send, default_dimensions: current.default_dimensions ?? {}, next_run_date: current.next_run_date, items: currentItems, } const { items: newItems, ...fieldChanges } = parsedChanges return stagePendingOperation( supabase, companyId, userId, 'update_recurring_schedule', `Uppdatera återkommande fakturaschema: ${current.name}`, parsed.data as unknown as Record, { current: currentPreview, changes: parsedChanges, proposed: { ...currentPreview, ...fieldChanges, ...(newItems ? { items: newItems } : {}), }, // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), }, actor, undefined, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, ] // Drift guard for the gnubok_get_agent_briefing recommended_tools loadouts: // every referenced tool must exist in the registry above and every referenced // skill must be a real workflow skill. Runs at module init so a rename or // removal fails the build (and every test importing this module) instead of // shipping a briefing that recommends phantom tools. assertRecommendedLoadoutsValid(new Set(tools.map((t) => t.name))) // ── MCP Protocol Handler ───────────────────────────────────── const SERVER_INFO_BY_NAMESPACE = { gnubok: { // Stable legacy identity for every existing connection. name: 'gnubok', title: 'Accounted', version: '1.0.0', }, accounted: { name: 'accounted', title: 'Accounted', version: '1.0.0', }, } as const const PROTOCOL_VERSION = '2025-06-18' // ── Spec revision 2026-07-28 (stateless core) ──────────────── // New-style clients skip the initialize handshake and instead carry their // protocol version and capabilities in _meta on every request. The handshake // path keeps serving 2025-06-18-and-earlier clients unchanged: their // responses stay byte-identical. const STATELESS_PROTOCOL_VERSION = '2026-07-28' const SUPPORTED_PROTOCOL_VERSIONS = [ STATELESS_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', ] const META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion' const META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo' // 2026-07-28 reserves -32020..-32099 for spec-defined errors. const JSONRPC_HEADER_MISMATCH = -32020 const JSONRPC_UNSUPPORTED_PROTOCOL_VERSION = -32022 // CacheableResult freshness hints. The tool/prompt catalog and widget HTML // change only on deploy; skills live in the DB and can change between // deploys; data resources are live ledger state and must never be cached. // Everything is served behind Authorization, so cacheScope stays private. const CACHE_STATIC = { ttlMs: 3_600_000, cacheScope: 'private' } as const const CACHE_SKILLS = { ttlMs: 300_000, cacheScope: 'private' } as const const CACHE_LIVE = { ttlMs: 0, cacheScope: 'private' } as const const SERVER_CAPABILITIES = { tools: { listChanged: false }, resources: { listChanged: false }, prompts: { listChanged: false }, extensions: { // MCP Apps (ratified extension): widgets are served as ui:// resources // and referenced from tool _meta.ui.resourceUri (see widgets/). 'io.modelcontextprotocol/ui': {}, // MCP Tasks: durable handles for long-running tool calls (see tasks.ts). [TASKS_EXTENSION_ID]: {}, }, } /** * Decode a standard-header value per the 2026-07-28 Value Encoding rules: * values outside plain ASCII arrive as =?base64??= and MUST be decoded * before comparing against the request body. Returns null for an absent * header so callers can distinguish "not sent" from "sent empty". */ function decodeMcpHeaderValue(value: string | null): string | null { if (value === null) return null const match = /^=\?base64\?(.*)\?=$/.exec(value) if (!match) return value try { return Buffer.from(match[1], 'base64').toString('utf8') } catch { return value } } function jsonRpc(id: string | number | null, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result } } function jsonRpcError( id: string | number | null, code: number, message: string, data?: unknown ): JsonRpcResponse { return { jsonrpc: '2.0', id, error: { code, message, data } } } /** * Schedule a fire-and-forget telemetry emit so it cannot race Vercel function * suspension: `after()` keeps the function alive past the JSON-RPC response * until the emit settles, which is why event_log inserts used to die with * "TypeError: fetch failed". Falls back to a plain fire-and-forget emit when * no Next request scope exists (direct handler invocation in tests). */ function emitAfterResponse(emit: () => Promise): void { try { after(emit) } catch { void emit() } } /** * Emit `mcp.tool_called` telemetry to the event bus. Fire-and-forget: the * dispatcher must never block the JSON-RPC response on telemetry, and a failing * handler must never surface to the client. The event bus already isolates * handlers via Promise.allSettled, but we belt-and-braces here too. */ function emitToolCallTelemetry(payload: { tool: string requiredScope: string | null actor: ActorContext latencyMs: number success: boolean isError: boolean errorCode: string | null errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'company_access_denied' | 'unknown_tool' | 'test_key_write_blocked' | null errorMessage: string | null requestId: string | number | null userId: string companyId: string }): void { emitAfterResponse(() => eventBus .emit({ type: 'mcp.tool_called', payload: { tool: payload.tool, requiredScope: payload.requiredScope, actorType: payload.actor.type, actorId: payload.actor.id ?? null, actorLabel: payload.actor.label ?? null, latencyMs: payload.latencyMs, success: payload.success, isError: payload.isError, errorCode: payload.errorCode, errorKind: payload.errorKind, // Truncated: domain error messages are short, but unknown-tool / // validation messages can embed long lists. 500 chars is plenty for // clustering failures into gotchas without bloating event_log rows. errorMessage: payload.errorMessage ? payload.errorMessage.slice(0, 500) : null, requestId: payload.requestId, userId: payload.userId, companyId: payload.companyId, sessionId: payload.actor.sessionId ?? null, client: payload.actor.client ?? null, }, }) .catch((err) => { // Last-resort guard. EventBus.emit already swallows handler failures, // but if the bus itself is in a bad state we still don't want to break tools. console.error('[mcp] tool_called telemetry emit failed:', err) })) } /** Fire-and-forget telemetry for a tools/list call. */ function emitToolsListTelemetry(payload: { toolCount: number actor: ActorContext latencyMs: number requestId: string | number | null userId: string companyId: string }): void { emitAfterResponse(() => eventBus .emit({ type: 'mcp.tools_list_called', payload: { toolCount: payload.toolCount, actorType: payload.actor.type, actorId: payload.actor.id ?? null, actorLabel: payload.actor.label ?? null, latencyMs: payload.latencyMs, requestId: payload.requestId, userId: payload.userId, companyId: payload.companyId, sessionId: payload.actor.sessionId ?? null, client: payload.actor.client ?? null, }, }) .catch((err) => { console.error('[mcp] tools_list_called telemetry emit failed:', err) })) } /** Fire-and-forget telemetry for a resources/read call. */ function emitResourceReadTelemetry(payload: { uri: string kind: 'widget' | 'skill' | 'data' | 'unknown' success: boolean errorCode: string | null actor: ActorContext latencyMs: number requestId: string | number | null userId: string companyId: string }): void { emitAfterResponse(() => eventBus .emit({ type: 'mcp.resource_read', payload: { uri: payload.uri, kind: payload.kind, success: payload.success, errorCode: payload.errorCode, latencyMs: payload.latencyMs, actorType: payload.actor.type, actorId: payload.actor.id ?? null, actorLabel: payload.actor.label ?? null, requestId: payload.requestId, userId: payload.userId, companyId: payload.companyId, sessionId: payload.actor.sessionId ?? null, client: payload.actor.client ?? null, }, }) .catch((err) => { console.error('[mcp] resource_read telemetry emit failed:', err) })) } /** * Per-session ring of "what was the most recent tool call, and what did its * response suggest as the `next` tool?" Used to detect `mcp.next_hint_followed` * when the agent's next call matches the previous nextHint.tool. * * In-memory only. Single-process visibility is acceptable for telemetry: a * miss in a multi-instance deploy only loses signal, never blocks a tool call. * Entries auto-expire after NEXT_HINT_TTL_MS to keep the map bounded. */ const NEXT_HINT_TTL_MS = 10 * 60 * 1000 const lastResponseHintBySession = new Map() function rememberNextHint(sessionId: string | null | undefined, fromTool: string, suggestedTool: string | undefined): void { if (!sessionId || !suggestedTool) return // Opportunistic eviction: drop a few expired entries on each write so the // map can't grow without bound under steady load. if (lastResponseHintBySession.size > 200) { const now = Date.now() for (const [k, v] of lastResponseHintBySession) { if (v.expiresAt < now) { lastResponseHintBySession.delete(k) if (lastResponseHintBySession.size < 100) break } } } lastResponseHintBySession.set(sessionId, { fromTool, suggestedTool, expiresAt: Date.now() + NEXT_HINT_TTL_MS, }) } function checkAndEmitNextHintFollowed( sessionId: string | null | undefined, toolName: string, actor: ActorContext, userId: string, companyId: string, ): void { if (!sessionId) return const prev = lastResponseHintBySession.get(sessionId) if (!prev || prev.expiresAt < Date.now() || prev.suggestedTool !== toolName) return // Consume the hint so we don't double-count if the agent calls the same // tool twice in a row (idempotent retries shouldn't inflate the metric). lastResponseHintBySession.delete(sessionId) emitAfterResponse(() => eventBus .emit({ type: 'mcp.next_hint_followed', payload: { fromTool: prev.fromTool, toTool: toolName, sessionId, actorType: actor.type, actorId: actor.id ?? null, actorLabel: actor.label ?? null, userId, companyId, }, }) .catch((err) => console.error('[mcp] next_hint_followed emit failed:', err))) } /** * Fire-and-forget telemetry for every successful gnubok_load_skill, all tiers. * Unlike mcp.workflow_started (workflow tier only), this records WHICH skill * or atom body the agent pulled: the denominator for correlating a loaded * atom with downstream tool-error rates. */ function emitSkillLoaded(payload: { slug: string tier: 'workflow' | 'horizontal' | 'vertical' | 'modifier' actor: ActorContext userId: string companyId: string }): void { emitAfterResponse(() => eventBus .emit({ type: 'mcp.skill_loaded', payload: { slug: payload.slug, tier: payload.tier, sessionId: payload.actor.sessionId ?? null, actorType: payload.actor.type, actorId: payload.actor.id ?? null, actorLabel: payload.actor.label ?? null, userId: payload.userId, companyId: payload.companyId, }, }) .catch((err) => console.error('[mcp] skill_loaded emit failed:', err))) } /** Fire-and-forget telemetry for workflow lifecycle. */ function emitWorkflowStarted(payload: { slug: string actor: ActorContext userId: string companyId: string }): void { emitAfterResponse(() => eventBus .emit({ type: 'mcp.workflow_started', payload: { slug: payload.slug, sessionId: payload.actor.sessionId ?? null, actorType: payload.actor.type, actorId: payload.actor.id ?? null, actorLabel: payload.actor.label ?? null, userId: payload.userId, companyId: payload.companyId, }, }) .catch((err) => console.error('[mcp] workflow_started emit failed:', err))) } /** * Handle an MCP JSON-RPC request. * Auth is done via Bearer API key (extension route has skipAuth: true). */ export async function handleMcpRequest(request: Request): Promise { const toolNamespace = resolveMcpToolNamespace(request) const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', appUrl) if (toolNamespace === 'accounted') { resourceMetadataUrl.searchParams.set('tool_namespace', 'accounted') } const wwwAuth = `Bearer resource_metadata="${resourceMetadataUrl.toString()}"` // ── Pre-auth: handle fire-and-forget notifications before auth check ── // MCP notifications have no id and don't expect error responses. // Checking auth on them would return 401 which confuses clients. const clonedRequest = request.clone() try { const peek = await clonedRequest.json() if (peek.method === 'notifications/initialized') { return new Response(null, { status: 202 }) } } catch { // Not valid JSON: fall through to auth + parse below } // ── Auth ── const token = extractBearerToken(request) if (!token) { return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': wwwAuth }, }) } const authResult = await validateApiKey(token) if ('error' in authResult) { const status = authResult.status if (status === 429) { return new Response(authResult.error, { status: 429, headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' }, }) } return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': wwwAuth }, }) } const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName, mode: keyMode } = authResult const supabase = createServiceClientNoCookies() // The Mcp-Session-Id header (introduced in spec 2025-06-18) is the canonical // way for an agent to keep a stable identifier across tools/call invocations // in one conversation. We use it to correlate telemetry + drive the next-hint // followed metric. It is NOT used for auth. const rawSessionId = request.headers.get('mcp-session-id') const sessionId = rawSessionId && /^[A-Za-z0-9_-]{1,128}$/.test(rawSessionId) ? rawSessionId : null // Distribution-channel marker: the Accounted bridge sends // `X-Accounted-Client`; the legacy bridge keeps `X-Gnubok-Client`. Both are // telemetry-only and share the same validation and storage path. const rawClient = request.headers.get('x-accounted-client') ?? request.headers.get('x-gnubok-client') ?? new URL(request.url).searchParams.get('client') const client = rawClient && /^[A-Za-z0-9._-]{1,64}$/.test(rawClient) ? rawClient.toLowerCase() : null const actor: ActorContext = { type: 'api_key', id: apiKeyId, label: apiKeyName ?? 'Unnamed API key', sessionId, client, } // ── Parse JSON-RPC ── let body: JsonRpcRequest try { body = await request.json() } catch { return NextResponse.json( jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'), { status: 400 } ) } if (body.jsonrpc !== '2.0' || !body.method) { return NextResponse.json( jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'), { status: 400 } ) } // ── Stateless core (spec 2026-07-28) ── // New-style clients carry their protocol version in _meta on every request // instead of an initialize handshake. Requests without the key come from // handshake-era clients and keep byte-identical responses. const requestMeta = (body.params?._meta ?? {}) as Record const metaVersion = requestMeta[META_PROTOCOL_VERSION] if (typeof metaVersion === 'string' && !SUPPORTED_PROTOCOL_VERSIONS.includes(metaVersion)) { return NextResponse.json( jsonRpcError( body.id ?? null, JSONRPC_UNSUPPORTED_PROTOCOL_VERSION, `Unsupported protocol version: "${metaVersion}"`, { supported: SUPPORTED_PROTOCOL_VERSIONS } ), { status: 400 } ) } // Revisions are ISO dates, so string comparison orders them correctly. const statelessClient = typeof metaVersion === 'string' && metaVersion >= STATELESS_PROTOCOL_VERSION // Tasks extension: only a client that declared it in THIS request's // capabilities may ever receive a CreateTaskResult. const taskCapable = statelessClient && isTaskCapableClient(requestMeta) // Standard request headers (2026-07-28): when present they must agree with // the JSON-RPC body. Absence stays accepted: this server supports // handshake-era clients (the spec sanctions that leniency), and the stdio // bridges do not send the headers. const headerProtocolVersion = request.headers.get('mcp-protocol-version') if ( headerProtocolVersion && typeof metaVersion === 'string' && headerProtocolVersion !== metaVersion ) { return NextResponse.json( jsonRpcError( body.id ?? null, JSONRPC_HEADER_MISMATCH, `Header mismatch: MCP-Protocol-Version "${headerProtocolVersion}" does not match _meta protocol version "${metaVersion}"` ), { status: 400 } ) } const headerMethod = request.headers.get('mcp-method') if (headerMethod && headerMethod !== body.method) { return NextResponse.json( jsonRpcError( body.id ?? null, JSONRPC_HEADER_MISMATCH, `Header mismatch: Mcp-Method "${headerMethod}" does not match body method "${body.method}"` ), { status: 400 } ) } // Mcp-Name mirrors params.name (tools/call, prompts/get) or params.uri // (resources/read); non-ASCII values arrive base64-wrapped and are decoded // before comparison. const headerName = decodeMcpHeaderValue(request.headers.get('mcp-name')) const bodyParamName = body.params?.name ?? body.params?.uri if (headerName !== null && typeof bodyParamName === 'string' && headerName !== bodyParamName) { return NextResponse.json( jsonRpcError( body.id ?? null, JSONRPC_HEADER_MISMATCH, `Header mismatch: Mcp-Name "${headerName}" does not match the request body name/uri "${bodyParamName}"` ), { status: 400 } ) } /** * Decorate a result for stateless-core clients: required resultType, * serverInfo identification, and CacheableResult freshness hints. A no-op * for handshake-era clients so existing connections see unchanged payloads. */ const decorate = ( result: Record, cache?: { ttlMs: number; cacheScope: 'public' | 'private' } ): Record => { if (!statelessClient) return result const decorated: Record = { resultType: 'complete', ...result } if (cache) { decorated.ttlMs = cache.ttlMs decorated.cacheScope = cache.cacheScope } decorated._meta = { ...((result._meta as Record | undefined) ?? {}), [META_SERVER_INFO]: SERVER_INFO_BY_NAMESPACE[toolNamespace], } return decorated } // ── Dispatch ── const { method, id, params } = body switch (method) { case 'server/discover': case 'initialize': { // Handshake-era set: a 2026-07-28 stateless client never sends // initialize; one that does anyway negotiates down to 2025-06-18. const HANDSHAKE_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']) const clientVersion = (params as Record)?.protocolVersion as string | undefined const negotiatedVersion = clientVersion && HANDSHAKE_VERSIONS.has(clientVersion) ? clientVersion : PROTOCOL_VERSION const instructions = projectToolReferencesInText([ 'Accounted: Swedish double-entry bookkeeping via conversation.', '', 'Discovery:', '• tools/list returns common tool schemas. Call gnubok_search_tools(query="…") for specialized tools: it ranks all capabilities; pass detail="name"|"summary"|"full" to control payload size.', '• gnubok_get_agent_briefing returns recommended_tools: ordered per-workflow tool loadouts (categorize_month, close_period, invoice_run, vat_declaration, payroll_month). If your harness defers tool loading, batch-load a whole workflow in one call (e.g. Claude Code ToolSearch select:a,b,c) instead of searching cluster by cluster.', `• This connection can work with every non-archived company the API-key user belongs to. Call gnubok_list_companies to discover company_id values. Omit company_id to use the API key default (${companyId}); when selecting another company, repeat company_id on every company-data call, including approval.`, '• MCP resources use the API key default company. For a selected non-default company, call gnubok_get_agent_briefing with company_id instead of relying on Accounted://company/current or other company-data resources.', '• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first: domain workflows are documented as loadable skills with tool references.', '', 'Common workflows:', '• Before categorizing or creating vouchers, consult ledger_context in gnubok_get_agent_briefing (full picture: the Accounted://ledger/context resource): it shows how THIS company has booked each counterparty and supplier (dominant account, VAT treatment, evidence = historical frequency). Prefer these observed patterns over guesses; explicit mapping rules outrank them. Frequency is not permission to auto-post: still stage for approval.', '• Categorize transactions: gnubok_list_uncategorized_transactions → gnubok_suggest_categories → gnubok_categorize_transaction (stages) → gnubok_approve_pending_operation (after user confirms in chat).', '• Applying income to invoices: pick by what you have: a specific bank transaction + a known invoice → gnubok_match_transaction_to_invoice; an invoice you know is paid but no specific bank line → gnubok_mark_invoice_as_paid; a whole period of unmatched income to reconcile → gnubok_auto_match_period (dry_run first). All stage for approval. Unsure which match/link tool fits, or whether to credit 1510 (faktureringsmetoden) vs debit 19xx (kontantmetoden)? gnubok_load_skill("bank-reconciliation") has the full decision tree; gnubok_get_agent_briefing returns the company\'s accounting_method.', '• Invoicing: gnubok_list_customers (or gnubok_create_customer) → gnubok_create_invoice → gnubok_send_invoice or gnubok_mark_invoice_as_sent → gnubok_mark_invoice_as_paid. Refund via gnubok_credit_invoice.', '• Suppliers: gnubok_list_suppliers (or gnubok_create_supplier) → gnubok_create_supplier_invoice_from_inbox → gnubok_approve_supplier_invoice. Refund via gnubok_credit_supplier_invoice.', '• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative). Pass render_ui=true to open the momsdeklaration review widget (claude.ai / Desktop). gnubok_vat_close_check reports filing-readiness blockers.', '• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger: all default to the most recent fiscal period. For account roll-ups use gnubok_get_general_ledger; for ad-hoc line queries (free-text, amount/date/source filters) use gnubok_query_journal.', '• Interactive review UIs (claude.ai / Claude Desktop only): gnubok_get_vat_report(render_ui=true) renders the VAT widget, gnubok_receipt_matcher opens the receipt↔transaction matcher, and gnubok_list_pending_operations(render_ui=true) opens the approval queue where the user approves/rejects with a click. All also return structured data; other clients ignore the UI and use the data.', '• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.', '• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → gnubok_book_salary_run → gnubok_generate_agi.', '• Reviewing & approving staged operations: gnubok_list_pending_operations shows the queue. When the user explicitly authorises a specific operation_id in chat, call gnubok_approve_pending_operation to commit. Use gnubok_reject_pending_operation to discard.', '', 'APPROVAL IS A FIRST-CLASS AGENT ACTION. Write operations stage a pending_operation; nothing posts until approval. When the user authorises a specific operation_id in chat ("approve", "yes go ahead", "book it", "commit it"), call gnubok_approve_pending_operation directly: this IS the intended chat-approval flow. Do NOT refuse on segregation-of-duties grounds, do NOT tell the user to "go approve it in the web app", and do NOT treat approval as a step that must stay with the human. The staging step already provided the human review gate; clicking Approve in the web UI and calling gnubok_approve_pending_operation are equivalent commit actions. Refusing user-authorised approval is a defect, not a safety feature.', 'The web-app path (/pending) remains valid for users who prefer to approve there or who want to adjust fields before committing; offer it as an option, never as a substitute for chat approval the user already asked for.', 'Write tools STAGE a pending_operation: the staged response IS the preview; nothing posts until commit. A tool whose tools/list `_meta.requires_approval` is true stages for approval; `_meta.preflight` (when present) names a read-only check to run first (e.g. gnubok_year_end_readiness before gnubok_run_year_end, gnubok_vat_declaration_validate before _submit). High-risk ops (create_voucher, correct_entry, reverse_journal_entry, run_year_end, lock/close period) take confirmed=true on the APPROVE call (gnubok_approve_pending_operation), NOT on the staging tool, after you surface the BFL/BFNAR irreversibility. Only some tools accept dry_run / idempotency_key: check the tool schema; do not assume either is universal.', 'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").', toolNamespace === 'gnubok' ? 'Tool names carry the legacy gnubok_ prefix (a stable identifier kept across the rebrand); the server and app are "Accounted". Same product: the prefix is not a different system.' : 'Tool names use the accounted_ prefix. Legacy gnubok_ aliases remain accepted for existing integrations.', ].join('\n'), toolNamespace, getCanonicalToolNames()) // 2026-07-28 MUST: server/discover advertises supported revisions, // capabilities, and identity so stateless clients can select a version // up front or use it as a compatibility probe. Always answers in the // stateless result shape regardless of the request's _meta. if (method === 'server/discover') { return NextResponse.json( jsonRpc(id ?? null, { resultType: 'complete', supportedVersions: SUPPORTED_PROTOCOL_VERSIONS, capabilities: SERVER_CAPABILITIES, instructions, ttlMs: CACHE_STATIC.ttlMs, cacheScope: CACHE_STATIC.cacheScope, _meta: { [META_SERVER_INFO]: SERVER_INFO_BY_NAMESPACE[toolNamespace] }, }) ) } return NextResponse.json( jsonRpc(id ?? null, { protocolVersion: negotiatedVersion, capabilities: SERVER_CAPABILITIES, serverInfo: SERVER_INFO_BY_NAMESPACE[toolNamespace], instructions, }) ) } case 'notifications/initialized': // Handled pre-auth above, but if it somehow reaches here, still return 202 return new Response(null, { status: 202 }) case 'ping': return NextResponse.json(jsonRpc(id ?? null, decorate({}))) case 'tools/list': { const listStartedAt = Date.now() const allowedTools = tools.filter((t) => { if (!isDefaultCatalogTool(t)) return false const required = TOOL_SCOPE_MAP[t.name] return !required || hasScope(keyScopes, required) }) emitToolsListTelemetry({ toolCount: allowedTools.length, actor, latencyMs: Date.now() - listStartedAt, requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ tools: allowedTools.map((t) => { // Merge derived staging metadata with any literal _meta (e.g. UI // widget hints). Literal _meta wins on key collision so explicit // tool config is never clobbered. const meta = projectMcpPayload( { ...(deriveToolMeta(t) ?? {}), ...(t._meta ?? {}) }, toolNamespace ) return projectMcpPayload( { name: toPublicToolName(t.name, toolNamespace), ...(t.title ? { title: t.title } : {}), description: t.description, inputSchema: projectToolInputSchema(t), ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), annotations: t.annotations, ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), }, toolNamespace ) }), }, CACHE_STATIC)) ) } case 'tools/call': { const rawRequestedToolName = (params as Record)?.name const requestedToolName = typeof rawRequestedToolName === 'string' ? rawRequestedToolName : '' const toolName = toCanonicalToolName(requestedToolName) const rawToolArgs = ((params as Record)?.arguments ?? {}) as Record< string, unknown > const tool = tools.find((t) => t.name === toolName) if (!tool) { emitToolCallTelemetry({ tool: toolName ?? '', requiredScope: null, actor, latencyMs: 0, success: false, isError: true, errorCode: 'UNKNOWN_TOOL', errorKind: 'unknown_tool', // Just the requested name: the full available-tools list returned // to the client would blow the truncation budget without adding // analytical signal. errorMessage: `Unknown tool: "${toolName}"`, requestId: id ?? null, userId, companyId, }) const available = tools .map((t) => toPublicToolName(t.name, toolNamespace)) .join(', ') return NextResponse.json( jsonRpcError( id ?? null, -32602, `Unknown tool: "${requestedToolName}". Available tools: ${available}` ) ) } // Enforce scope: surface structured error so the agent can dispatch. const requiredScope = TOOL_SCOPE_MAP[toolName] if (requiredScope && !hasScope(keyScopes, requiredScope)) { const scopeError = toToolError( new Error(`Insufficient scope: this API key does not have the "${requiredScope}" scope`), { toolName } ) emitToolCallTelemetry({ tool: toolName, requiredScope, actor, latencyMs: 0, success: false, isError: true, errorCode: scopeError.error.code, errorKind: 'scope_denied', errorMessage: scopeError.error.message_sv, requestId: id ?? null, userId, companyId, }) const publicScopeError = projectMcpPayload(scopeError, toolNamespace) return NextResponse.json( jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicScopeError, null, 2) }], isError: true, })) ) } let toolArgs: Record let effectiveCompanyId = companyId const companyRoutingStartedAt = Date.now() try { const extracted = extractRequestedCompany(rawToolArgs) toolArgs = extracted.toolArgs if (isCompanyDependentTool(toolName)) { const companyContext = await resolveMcpCompanyContext({ supabase, userId, defaultCompanyId: companyId, requestedCompanyId: extracted.requestedCompanyId, }) assertMcpCompanyWriteAccess(companyContext, requiredScope) effectiveCompanyId = companyContext.companyId } } catch (err) { const structured = toToolError(err, { toolName }) const publicStructured = projectMcpPayload(structured, toolNamespace) emitToolCallTelemetry({ tool: toolName, requiredScope: requiredScope ?? null, actor, latencyMs: Date.now() - companyRoutingStartedAt, success: false, isError: true, errorCode: structured.error.code, errorKind: 'company_access_denied', errorMessage: structured.error.message_sv, requestId: id ?? null, userId, // Keep denied attempts attributed to the key default. An arbitrary, // unauthorized target must never create tenant telemetry there. companyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }], isError: true, })) ) } // Enforce the capability paywall: the MCP/agent path is a paid chokepoint // just like the HTTP routes (send_invoice → email_send, the two SKV // submissions → skatteverket). Fail-closed; self-hosted short-circuits to // all-on inside hasCapability. Blocks before any pending op is staged. const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName] if (requiredCapability && !(await hasCapability(supabase, effectiveCompanyId, requiredCapability))) { const capError = { error: capabilityBlockedError(requiredCapability) } const publicCapError = projectMcpPayload(capError, toolNamespace) emitToolCallTelemetry({ tool: toolName, requiredScope, actor, latencyMs: 0, success: false, isError: true, errorCode: capError.error.code, errorKind: 'capability_denied', errorMessage: capError.error.message_sv, requestId: id ?? null, userId, companyId: effectiveCompanyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicCapError, null, 2) }], isError: true, })) ) } // Test-mode API keys are simulation-only. Mirror the v1 REST guard // (lib/api/v1/with-api-v1.ts): force dry-run on any write tool that // supports it, and block writes that cannot be simulated. Without this a // gnubok_sk_test_ key (which is bound to the real active company) could // stage real pending_operations here and, with the approve scope, commit // them. Runs before execute() so nothing is ever staged for a test key. if (keyMode === 'test' && tool.annotations?.readOnlyHint === false) { const props = (tool.inputSchema as { properties?: Record } | undefined) ?.properties if (props && 'dry_run' in props) { ;(toolArgs as Record).dry_run = true } else { const blocked = toToolError( new Error( 'Test-nyckel kan inte utföra riktiga skrivningar mot det här verktyget. Använd en live-nyckel för skarpa operationer.' ), { toolName } ) const publicBlocked = projectMcpPayload(blocked, toolNamespace) emitToolCallTelemetry({ tool: toolName, requiredScope: requiredScope ?? null, actor, latencyMs: 0, success: false, isError: true, errorCode: blocked.error.code, errorKind: 'test_key_write_blocked', errorMessage: blocked.error.message_sv, requestId: id ?? null, userId, companyId: effectiveCompanyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicBlocked, null, 2) }], isError: true, })) ) } } // Detect if THIS call follows the previous call's `next` hint: must // run before execute() so we don't double-store on this call. Emits // mcp.next_hint_followed when the agent's behaviour matches the hint. checkAndEmitNextHintFollowed(sessionId, toolName, actor, userId, effectiveCompanyId) // ── Tasks extension (io.modelcontextprotocol/tasks) ── // Long-running tools return a durable handle immediately to a client // that declared the extension; the work completes after the response // (after() keeps the function alive) and the result lands in mcp_tasks // for tasks/get polling. Runs after every auth/scope/capability guard // so nothing is ever started for a call that would have been refused. if (taskCapable && tool.shouldRunAsTask?.(toolArgs)) { const task = await createMcpTask(supabase, { companyId: effectiveCompanyId, userId, apiKeyId, toolName, }) const taskStartedAt = Date.now() emitAfterResponse(async () => { try { const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor) const canonicalResult = addCompanyToTopLevelNext(rawResult, effectiveCompanyId) const result = projectMcpPayload(canonicalResult, toolNamespace) const stored: Record = { resultType: 'complete', content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], } if (result !== null && result !== undefined) { stored.structuredContent = typeof result === 'object' && !Array.isArray(result) ? result : { value: result } } await resolveMcpTask(supabase, task.id, { status: 'completed', result: stored }) emitToolCallTelemetry({ tool: toolName, requiredScope: requiredScope ?? null, actor, latencyMs: Date.now() - taskStartedAt, success: true, isError: false, errorCode: null, errorKind: null, errorMessage: null, requestId: id ?? null, userId, companyId: effectiveCompanyId, }) } catch (err) { // Tool failures complete the task with the standard isError // envelope: exactly what the synchronous call would have // returned. `failed` stays reserved for infrastructure errors. const structured = toToolError(err, { toolName }) const publicStructured = projectMcpPayload(structured, toolNamespace) await resolveMcpTask(supabase, task.id, { status: 'completed', result: { resultType: 'complete', content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }], isError: true, }, statusMessage: structured.error.message_sv, }).catch((updateErr) => { log.error('Failed to store MCP task failure result', { taskId: task.id, updateErr }) }) emitToolCallTelemetry({ tool: toolName, requiredScope: requiredScope ?? null, actor, latencyMs: Date.now() - taskStartedAt, success: false, isError: true, errorCode: structured.error.code, errorKind: 'execution', errorMessage: structured.error.message_sv, requestId: id ?? null, userId, companyId: effectiveCompanyId, }) } }) return NextResponse.json( jsonRpc(id ?? null, { resultType: 'task', task: taskToWire(task), _meta: { [META_SERVER_INFO]: SERVER_INFO_BY_NAMESPACE[toolNamespace] }, }) ) } const callStartedAt = Date.now() try { // gnubok_search_tools needs the caller's scopes to filter results to // what the API key can actually invoke. Inject privately via __keyScopes. if (toolName === 'gnubok_search_tools') { (toolArgs as Record).__keyScopes = keyScopes ;(toolArgs as Record).__toolNamespace = toolNamespace } const rawResult = await tool.execute(toolArgs, effectiveCompanyId, userId, supabase, actor) const canonicalResult = addCompanyToTopLevelNext(rawResult, effectiveCompanyId) const result = projectMcpPayload(canonicalResult, toolNamespace) const latencyMs = Date.now() - callStartedAt const response: Record = { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], } // Emit structuredContent for every tool: clients with outputSchema support // can consume this directly without re-parsing the JSON-stringified text block. // structuredContent must be an object, so wrap non-objects. if (result !== null && result !== undefined) { response.structuredContent = typeof result === 'object' && !Array.isArray(result) ? result : { value: result } } // Result-level UI hint: render the widget only when the caller opted in // via render_ui=true. This keeps the merged report+widget tool data-only // by default and never sends a render directive a plain-data call didn't ask for. if (tool.uiResourceUri && (toolArgs as Record).render_ui === true) { response._meta = { ui: { resourceUri: tool.uiResourceUri } } } // Record the response's `next.tool` (when present) so the next call // from the same session can be matched against it. if ( canonicalResult && typeof canonicalResult === 'object' && !Array.isArray(canonicalResult) ) { const next = (canonicalResult as Record).next if (next && typeof next === 'object') { const suggestedTool = (next as Record).tool if (typeof suggestedTool === 'string') { rememberNextHint(sessionId, toolName, suggestedTool) } } } emitToolCallTelemetry({ tool: toolName, requiredScope: requiredScope ?? null, actor, latencyMs, success: true, isError: false, errorCode: null, errorKind: null, errorMessage: null, requestId: id ?? null, userId, companyId: effectiveCompanyId, }) return NextResponse.json(jsonRpc(id ?? null, decorate(response))) } catch (err) { const latencyMs = Date.now() - callStartedAt const structured = toToolError(err, { toolName }) const publicStructured = projectMcpPayload(structured, toolNamespace) emitToolCallTelemetry({ tool: toolName, requiredScope: requiredScope ?? null, actor, latencyMs, success: false, isError: true, errorCode: structured.error.code, errorKind: 'execution', // message_sv is the canonical domain message ("Verifikationen // balanserar inte", "Perioden är låst", …): the text worth // clustering when mining failures for gotchas. errorMessage: structured.error.message_sv, requestId: id ?? null, userId, companyId: effectiveCompanyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ content: [{ type: 'text', text: JSON.stringify(publicStructured, null, 2) }], isError: true, })) ) } } case 'resources/list': { const allSkills = await loadAllSkills(supabase) return NextResponse.json( jsonRpc(id ?? null, decorate(projectMcpPayload({ resources: [ ...uiWidgets.map((w) => ({ uri: w.uri, name: w.name, description: w.description, mimeType: WIDGET_MIME_TYPE, })), ...allSkills.map((s) => ({ uri: skillUri(s.slug), name: s.name, description: s.summary, mimeType: SKILL_MIME_TYPE, })), ...dataResources.map((r) => ({ uri: r.uri, name: r.name, description: r.description, mimeType: r.mimeType, })), ], }, toolNamespace), CACHE_SKILLS)) ) } case 'resources/read': { const uri = (params as Record)?.uri as string const readStartedAt = Date.now() const widget = findUiWidget(uri) if (widget) { emitResourceReadTelemetry({ uri, kind: 'widget', success: true, errorCode: null, actor, latencyMs: Date.now() - readStartedAt, requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ contents: [ { uri, mimeType: WIDGET_MIME_TYPE, text: projectToolReferencesInText( widget.html, toolNamespace, getCanonicalToolNames() ), }, ], }, CACHE_STATIC)) ) } // Skills exposed at Accounted://skill/: Markdown bodies, forward-compatible // with a future native MCP skills/list primitive. Atom slugs (slash-bearing // registry ids) are URL-encoded in the URI; skillSlugFromUri decodes. if (uri.startsWith(SKILL_URI_PREFIX)) { const slug = skillSlugFromUri(uri) const skill = slug ? await findSkill(slug, supabase) : null if (skill) { emitResourceReadTelemetry({ uri, kind: 'skill', success: true, errorCode: null, actor, latencyMs: Date.now() - readStartedAt, requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ contents: [ { uri, mimeType: SKILL_MIME_TYPE, text: projectToolReferencesInText( skill.body, toolNamespace, getCanonicalToolNames() ), }, ], }, CACHE_SKILLS)) ) } } const dataResource = findResource(uri) if (dataResource) { try { const result = await dataResource.read({ supabase, companyId, userId, scopes: keyScopes, query: parseResourceQuery(uri), }) emitResourceReadTelemetry({ uri, kind: 'data', success: true, errorCode: null, actor, latencyMs: Date.now() - readStartedAt, requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ contents: [ { uri, mimeType: dataResource.mimeType, text: JSON.stringify(projectMcpPayload(result, toolNamespace), null, 2), }, ], }, CACHE_LIVE)) ) } catch (err) { const message = err instanceof Error ? err.message : 'Resource read failed' emitResourceReadTelemetry({ uri, kind: 'data', success: false, errorCode: 'RESOURCE_READ_FAILED', actor, latencyMs: Date.now() - readStartedAt, requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpcError( id ?? null, -32603, projectToolReferencesInText( `Resource read error: ${message}`, toolNamespace, getCanonicalToolNames() ) ) ) } } emitResourceReadTelemetry({ uri, kind: 'unknown', success: false, errorCode: 'RESOURCE_NOT_FOUND', actor, latencyMs: Date.now() - readStartedAt, requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpcError(id ?? null, -32602, `Resource not found: "${uri}"`) ) } case 'prompts/list': return NextResponse.json( jsonRpc(id ?? null, decorate(projectMcpPayload({ prompts: prompts.map((p) => ({ name: p.name, description: p.description, })), }, toolNamespace), CACHE_STATIC)) ) case 'prompts/get': { const promptName = (params as Record)?.name as string const prompt = findPrompt(promptName) if (!prompt) { return NextResponse.json( jsonRpcError(id ?? null, -32602, `Unknown prompt: "${promptName}"`) ) } return NextResponse.json( jsonRpc(id ?? null, decorate({ description: prompt.description, messages: [ { role: 'user', content: { type: 'text', text: projectToolReferencesInText( prompt.text, toolNamespace, getCanonicalToolNames() ), }, }, ], })) ) } case 'tasks/get': { const taskId = (params as Record)?.taskId if (typeof taskId !== 'string' || !taskId) { return NextResponse.json(jsonRpcError(id ?? null, -32602, 'taskId is required')) } // Scoped to the creating user: an API key can only poll its own tasks. const { data: taskRow } = await supabase .from('mcp_tasks') .select('*') .eq('id', taskId) .eq('user_id', userId) .single() if (!taskRow) { return NextResponse.json(jsonRpcError(id ?? null, -32602, `Task not found: "${taskId}"`)) } const row = taskRow as McpTaskRow const wire: Record = { resultType: 'complete', ...taskToWire(row) } if (row.status === 'completed' && row.result) wire.result = row.result if (row.status === 'failed' && row.error) wire.error = row.error wire._meta = { [META_SERVER_INFO]: SERVER_INFO_BY_NAMESPACE[toolNamespace] } return NextResponse.json(jsonRpc(id ?? null, wire)) } case 'tasks/update': // No input_required flows exist yet: acknowledge and ignore unknown or // already-satisfied inputResponses, as the extension spec instructs. return NextResponse.json(jsonRpc(id ?? null, { resultType: 'complete' })) case 'tasks/cancel': { const taskId = (params as Record)?.taskId if (typeof taskId !== 'string' || !taskId) { return NextResponse.json(jsonRpcError(id ?? null, -32602, 'taskId is required')) } // Cooperative cancellation: flip a still-working row; an in-flight // execution is not interrupted, and its late completion becomes a // no-op against the now-terminal row. await supabase .from('mcp_tasks') .update({ status: 'cancelled' }) .eq('id', taskId) .eq('user_id', userId) .eq('status', 'working') return NextResponse.json(jsonRpc(id ?? null, { resultType: 'complete' })) } default: return NextResponse.json( jsonRpcError(id ?? null, -32601, `Method not found: "${method}"`) ) } }