import { UUID_RE } from '@/lib/invariants/uuid' import { NextResponse, after } from 'next/server' import { TASKS_EXTENSION_ID, isTaskCapableClient, createMcpTask, resolveMcpTask, taskToWire, type McpTaskRow, } from './tasks' import { extractBearerToken, validateApiKey, createServiceClientNoCookies, hasScope, RATE_LIMIT_RETRY_AFTER_SECONDS, TOOL_SCOPE_MAP, type ApiKeyMode, type ApiKeyScope, } from '@/lib/auth/api-keys' import { checkRateLimit } from '@/lib/auth/rate-limit-http' import { getCanonicalBaseUrl } from '@/lib/api/v1/base-url' import { createCompanyCore } from '@/lib/company/create-company' import { CompanySetupSchema, planCompanySetup } from '@/lib/company/onboarding-input' import { lookupCompanyByOrgNumber } from '@/extensions/general/tic/lib/lookup' import { TICAPIError } from '@/extensions/general/tic/lib/tic-types' import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' import { mapEntityType } from '@/lib/company-lookup/entity-type-map' import { deriveFirstYearDefaults, parseStartMonthDay } from '@/lib/company/first-year-defaults' import { ANONYMOUS_METHODS, ANONYMOUS_RATE_LIMIT, anonymousRateLimitIdentifier, isPublicTool, } from './public-tools' import { isEagerAuthRequested } from './auth-mode' import { createLogger } from '@/lib/logger' import { roundOre, sumOre } from '@/lib/money' import { currentAppVersion } from '@/lib/reports/app-version' import type { DayValueEmployee } from '@/lib/salary/semesterberedning' import { getVatDeadlineForPeriod, type VatDeadlineCalculationSettings, } from '@/lib/tax/deadline-config' import { adjustDeadlineToNextBankingDay } from '@/lib/tax/swedish-holidays' import type { SupabaseClient } from '@supabase/supabase-js' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { applyAccountOverride } from '@/lib/bookkeeping/account-override' import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number' import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines' import { getErrorEntry } from '@/lib/errors/structured-errors' import { ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors' import { dbError, errorCauseTag } from '@/lib/errors/db-error' import { getStructuredError } from '@/lib/errors/get-structured-error' 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 { setTransactionIgnored } from '@/lib/transactions/ignore' import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle' import { eventBus } from '@/lib/events/bus' import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules' import { validateDeductionLines } from '@/lib/invoices/rot-rut-rules' import { computeLineNet } from '@/lib/invoices/line-amounts' import { resolveSupplierInvoiceExchangeRate } from '@/lib/currency/supplier-invoice-rate' 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, resolvePeriodDates, } from '@/lib/reports/vat-declaration' import { fetchDynamicVatAccounts } 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, rcBasisTotalsByRate, type RcBasisGapScan, } from '@/lib/reports/vat-filing-gate' import { findRcBasisGaps } from '@/lib/reports/rc-basis-gaps' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { listForCompany as listCashAccountsForCompany } from '@/lib/cash-accounts/service' import { looksLikeSwedishPersonalNumber, normalizeReroutedPersonalNumber, orgNumberHoldsPersonalNumber, personalNumberDigits, } from '@/lib/customers/personal-number-shape' import { PERSONAL_NUMBER_PLAINTEXT_RE, isMaskedPersonalNumber, maskCustomerPersonalNumber, } from '@/lib/customers/mask-personal-number' import { encryptCustomerPersonalNumber, maskStoredCustomerPersonalNumber, } from '@/lib/customers/protect-personal-number' import { resolveDefaultPaymentTerms } from '@/lib/customers/default-payment-terms' import { fetchEntryLines, fetchLinesByEntryIds, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { generateARLedger } from '@/lib/reports/ar-ledger' import { fetchInvoiceRegisterCoverage, NO_INVOICE_REGISTER_COVERAGE, } from '@/lib/invoices/invoice-register-coverage' 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 { COUNTRY_CONSISTENCY_MESSAGES, checkCountryConsistency, defaultCountryForParty, normalizeCountryCode, } from '@/lib/vat/country-codes' import { ACCOUNT_VAT_TREATMENTS, defaultRateForVatTreatment, isAccountVatTreatment, isVatTreatmentAllowedForAccountClass, } from '@/lib/vat/account-vat-treatment' 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 { effectiveQuoteStatus } from '@/lib/invoices/quote-status' 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, } from '@/lib/api/idempotency' import { toToolError, type NextActionHint } from './tool-result' import { addCompanyToNextHint, addCompanyToTopLevelNext, assertMcpCompanyWriteAccess, codedError, extractRequestedCompany, isCompanyDependentTool, isOptionalCompanyTool, noCompanyYetError, projectToolInputSchema, resolveMcpCompanyContext, } from './company-routing' import { findUnknownArgKeys, listArgKeys, shortestExampleFor } from './arg-guard' import { findSupplierCandidates, type SupplierRow } from './supplier-candidates' import { matchSupplierByIdentity, matchSupplierId, supplierIdentityFrom, } from '@/lib/suppliers/match-supplier' import { assertNoPlaintextPersonnummer } from './staging-pii-guard' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' import { generateGeneralLedger } from '@/lib/reports/general-ledger' // Account-keyed reconciliation (one engine, three doors): the same service // the dashboard routes and the v1 API call. import { getAccountStatus } from '@/lib/reconciliation/service' import { listAccountItems } from '@/lib/reconciliation/items' import { matchPairs } from '@/lib/reconciliation/actions' import { signOffAccount } from '@/lib/reconciliation/signoff' import { bookResidualAndLink, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual' import { parseAccountKey, type ReconciliationItemBucket } from '@/lib/reconciliation/schemas' 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 { 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 { CreateInvoiceFromSalesOrderSchema, CreateSalesOrderSchema, RegisterSalesOrderDeliverySchema, RotRutBeslutFileSchema, SalesOrderTransitionSchema, } from '@/lib/api/schemas' import { decorate as decorateSalesOrder, fetchInvoicedQuantities, loadSalesOrder } from '@/lib/sales-orders/load' import { normalizeSalesOrderLines } from '@/lib/sales-orders/lines' import { hasOpenInvoices } from '@/lib/sales-orders/write' import { pickLines } from '@/lib/sales-orders/create-invoice-from-order' import type { ServiceFailure } from '@/lib/sales-orders/result' import { findMatchingVouchersForInvoice, validateVoucherForInvoiceLink, } from '@/lib/invoices/voucher-matching' import { findMatchingVouchersForSupplierInvoice, validateVoucherForSupplierInvoiceLink, } from '@/lib/invoices/supplier-voucher-matching' import { findFiscalPeriod, getSwedishLocalDate, validateBalance } from '@/lib/bookkeeping/engine' import { countUnbookedInPeriod, findNextPeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service' import { assessKontantmetodCutoff, cutoffPreviewFingerprint, hasIncompleteKontantmetodCutoffPair, KONTANTMETOD_CUTOFF_DESCRIPTIONS, nextDay, reverseLines, } from '@/lib/core/bookkeeping/kontantmetod-cutoff' import { generateSIEExport } from '@/lib/reports/sie-export' import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export' import { CorrectionChainTooDeepError } from '@/lib/bookkeeping/errors' import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain' 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 { getEmailService } from '@/lib/email/service' import { hasCapability, capabilityBlockedError } from '@/lib/entitlements/has-capability' import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys' import { triggerConnectionSync } from '@/extensions/general/enable-banking/lib/trigger-sync' import { completePendingDocumentUpload, createPendingDocumentUpload, uploadDocument, MAX_DOCUMENT_SIZE, buildPendingDocumentStoragePath, DOCUMENTS_BUCKET, } from '@/lib/core/documents/document-service' import { toSameOriginStorageUrl } from '@/lib/core/documents/storage-proxy' import { createHash } from 'node:crypto' import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema, AgentExtractionSchema, fetchOwnCompanyIdentity } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' import { mirrorExtractionToDocument } from '@/extensions/general/invoice-inbox/lib/mirror-extraction' // 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 { readAgiSubmissionStatus } from '@/extensions/general/skatteverket/lib/agi-submission-status' 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 { findCompanyTokenUser, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth' import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config' // Stage-time preview for book_skattekonto_row(s): same deterministic rule // matcher the skattekonto list page and the booking commit path use. The // actual booking runs in the extension's registry-resolved commit service on // approval; staging never books. import { attachBookingSuggestions } from '@/extensions/general/skatteverket/lib/skattekonto-booking' import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill' 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, YearEndBlockerCode, SalesOrder, SalesOrderItem, SalesOrderStatus } from '@/types' // ── Actor context ──────────────────────────────────────────── type StagedInvoiceLineInput = { line_type?: 'product' | 'text' description?: string quantity: number unit?: string unit_price?: number /** Line discount 0-100 (rabatt i procent); totals are computed net of it. */ discount_percent?: number vat_rate?: number article_id?: string revenue_account?: string | null // ROT/RUT claim fields (CreateInvoiceItemSchema parity). The housing // columns are property identifiers, never the personnummer: the stored // personnummer exists only as ciphertext and never crosses the MCP surface. deduction_type?: 'rot' | 'rut' | null labor_hours?: number | null work_type?: string | null housing_designation?: string | null apartment_number?: string | null brf_org_number?: string | null // Periodisering (förutbetald intäkt): pass-through to the builder. accrual_period_start?: string | null accrual_period_end?: string | null accrual_balance_account?: string | null dimensions?: unknown } type ResolvedInvoiceLine = StagedInvoiceLineInput & { description: string unit: string unit_price: number } type InvoiceLineArticle = { id: string name: string unit: string | null price_excl_vat: number | null vat_rate: number | null revenue_account: string | null currency: string | null active: boolean } /** * Article prefill for one staged invoice line (web line picker parity, * InvoiceEditor's applyArticle): the line's own values win and the article * fills whatever the agent left out. The article's VAT rate is adopted ONLY * when it is in the customer's DEFAULT rate set (adoptableVatRates, empty for * a customer locked to a single rate): an article's stored rate is its * domestic rate, and adopting it against the wider permitted set would * silently put Swedish VAT on a reverse-charge or export invoice. */ function resolveInvoiceLineFromArticle( item: StagedInvoiceLineInput, article: InvoiceLineArticle | undefined, currency: string, adoptableVatRates: ReadonlySet, index: number, ): ResolvedInvoiceLine { const lineNo = index + 1 // Free-text / spacer rows (web parity, CreateInvoiceItemSchema): no // amounts, never book, exempt from the quantity/description/unit/price // gates. Normalized to the exact zeroed shape build-invoice-write stores so // a gnubok_get_invoice line passes back verbatim. if (item.line_type === 'text') { if (item.article_id) { throw new Error(`Item ${lineNo}: a text row cannot carry article_id (drop line_type or article_id)`) } return { ...item, description: item.description ?? '', quantity: 0, unit: '', unit_price: 0, discount_percent: 0, vat_rate: 0, } } if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${lineNo}: quantity must be positive`) // Strict typeof: a host that skips inputSchema validation could send a // string, which JS comparisons would coerce past a bare range check while // hasLineDiscount (typeof === 'number') then ignores it in the totals. if ( item.discount_percent != null && (typeof item.discount_percent !== 'number' || !(item.discount_percent >= 0 && item.discount_percent <= 100)) ) { throw new Error(`Item ${lineNo}: discount_percent must be a number between 0 and 100`) } if (item.article_id && !article) { throw new Error(`Item ${lineNo}: article ${item.article_id} not found in this company. Use gnubok_list_articles to find valid IDs.`) } if (article && !article.active) { throw new Error(`Item ${lineNo}: article "${article.name}" is deactivated. Reactivate it with gnubok_update_article (active: true) or drop article_id.`) } const description = item.description?.trim() || article?.name if (!description) throw new Error(`Item ${lineNo}: description is required (or set article_id)`) const unit = item.unit?.trim() || (article ? article.unit || 'st' : undefined) if (!unit) throw new Error(`Item ${lineNo}: unit is required (st, tim, dag)`) let unitPrice = item.unit_price if (unitPrice == null && article) { if (article.price_excl_vat == null) { throw new Error(`Item ${lineNo}: article "${article.name}" has no price; pass unit_price explicitly.`) } if (article.currency && article.currency !== currency) { throw new Error( `Item ${lineNo}: article "${article.name}" is priced in ${article.currency} but the invoice is in ${currency}. ` + `Set the invoice currency to ${article.currency} or pass unit_price explicitly.` ) } unitPrice = article.price_excl_vat } if (unitPrice == null) throw new Error(`Item ${lineNo}: unit_price is required (or set article_id)`) return { ...item, description, unit, unit_price: unitPrice, ...(item.vat_rate == null && article?.vat_rate != null && adoptableVatRates.has(article.vat_rate) ? { vat_rate: article.vat_rate } : {}), ...(item.revenue_account == null && article?.revenue_account ? { revenue_account: article.revenue_account } : {}), } } interface ActorContext { // 'anonymous': a client that has not connected an account yet (lazy // authentication, issue #1814). Only PUBLIC_TOOLS ever run under it. type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' | 'anonymous' 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 /** * Approval authority for this key in SEK: the largest amount it may commit * with no human in the loop, or null/undefined for unlimited (the default, * and what every key created before this column existed has). * * Only meaningful for `type: 'api_key'`. Read once at auth time so the * ceiling cannot drift mid-request. */ unattendedCommitLimit?: number | 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 } // Shared annotation sets. tools/list serialises `annotations` as-is, so a // shared frozen object emits exactly the JSON an inline literal did; nothing // mutates a tool's annotations after registration. Tools whose hints need // a per-tool explanation keep an inline block with comments. const ANNOTATIONS_READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, } as const satisfies McpToolAnnotations /** Stages a pending operation or writes once; retries are not idempotent. */ const ANNOTATIONS_STAGED_WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, } as const satisfies McpToolAnnotations const ANNOTATIONS_IDEMPOTENT_WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, } as const satisfies McpToolAnnotations const ANNOTATIONS_DESTRUCTIVE_WRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, } as const satisfies McpToolAnnotations const ANNOTATIONS_DESTRUCTIVE_IDEMPOTENT_WRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false, } as const satisfies McpToolAnnotations /** Read-only tools that call an external system (Skatteverket, Riksbanken). */ const ANNOTATIONS_READ_ONLY_OPEN_WORLD = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, } as const satisfies McpToolAnnotations /** Writes that also reach an external system. */ const ANNOTATIONS_WRITE_OPEN_WORLD = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, } as const satisfies McpToolAnnotations /** * A domain failure carrying a structured-errors registry code, so the dispatch * envelope (toToolError → getStructuredError) resolves the registry entry * instead of UNKNOWN_ERROR. The Swedish registry message is the thrown text. */ function registryError(code: string): Error { return Object.assign(new Error(getErrorEntry(code)?.message_sv ?? code), { code }) } 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' // Matcher-side search synonyms (mainly Swedish domain terms like // "verifikat", "kundfaktura", "avstämning") folded into gnubok_search_tools // matching only. NEVER serialized: keywords must not appear in tools/list // or search_tools output; the tools/list payload budget has zero headroom. // Store lowercase. keywords?: string[] _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 extraction = await extractInvoiceFields({ buffer, mimeType, fileName, ownCompany: await fetchOwnCompanyIdentity(supabase, companyId), }) const { data: extracted } = extraction // uploadDocument()/completePendingDocumentUpload() were told this inbox // item owns extraction, so the document-extraction extension yielded; // mirror the outcome onto the document row in its place. await mirrorExtractionToDocument(documentId, { data: extracted, rawText: extraction.rawText, model: extraction.model ?? null, skipped: extraction.skipped ?? null, }) const matchedSupplierId = await matchSupplierId(supabase, companyId, extracted.supplier) 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}$/ /** * `reason` is capped at 500 characters (inputSchema maxLength). Hosts do not * always enforce inputSchema, so the tools re-check at runtime. The message * text is what the error layer keys on: getStructuredError infers * VALIDATION_ERROR from it and getErrorMessage maps it to a specific Swedish * line (a structured `code` on the error would win over the message and * collapse the Swedish text to the generic validation fallback). */ function reasonTooLongError(): Error { return new Error('reason must be 500 characters or fewer') } /** * Coerce a single account-number argument to a trimmed string (validated * against ACCOUNT_NUMBER_RE from lib/invariants/account-number). Accepts a * string or a finite integer (hosts that skip inputSchema validation send * `1930` as a JSON number); everything else is a validation error rather than * a silently dropped filter. */ function normalizeAccountNumber(value: unknown, field: string): string | undefined { if (value === undefined || value === null || value === '') return undefined const str = typeof value === 'number' && Number.isInteger(value) ? String(value) : typeof value === 'string' ? value.trim() : null if (str === null || !ACCOUNT_NUMBER_RE.test(str)) { throw new Error(`${field} must be an account number string like "1930" (got ${JSON.stringify(value)})`) } return str } /** * Coerce an `accounts` argument to string[]. Accepts an array of * strings/integers, a bare string ("1930" or comma-separated "1930,1940"), or * a bare integer. Returns undefined for null/empty so callers fall through to * account_from/account_to. */ function normalizeAccountList(value: unknown): string[] | undefined { if (value === undefined || value === null) return undefined const raw: unknown[] = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [value] const list = raw .filter((v) => v != null && !(typeof v === 'string' && v.trim() === '')) .map((v) => normalizeAccountNumber(v, 'accounts') as string) return list.length > 0 ? list : undefined } 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 /** * Payload hashed for the idempotency replay check instead of `params`. * Needed when params carry a non-deterministic derivative of the input * (random-IV ciphertext, see gnubok_create_customer): hashing params would * make an identical retry look like a different payload and fail with * IDEMPOTENCY_KEY_REUSE. Must itself be free of plaintext PII. */ idempotencyParams?: Record /** * 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 /** * Advisory compliance warning. Appended to the staged message and echoed as * preview.compliance_warning, which persists into preview_data so the * /pending approval card carries the same warning. Never blocks: the DB * triggers and the approver stay authoritative. */ complianceNote?: string } // Keys that can stage typically lack pending_operations:approve (segregation // of duties, see STAGING_SCOPES in lib/auth/api-keys.ts), so the approve tool // is filtered out of their tools/list. Every staging response names the web // fallback so agents on such keys don't hunt for a tool they cannot see. const APPROVE_SCOPE_FALLBACK = 'If that tool is missing from your catalog, this API key lacks the pending_operations:approve scope: the user approves at /pending instead.' 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, and any compliance_warning in the preview, 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. ${APPROVE_SCOPE_FALLBACK}` } return `When the user authorises, call gnubok_approve_pending_operation with operation_id="${operationId}". ${APPROVE_SCOPE_FALLBACK}` } 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') // Fold the advisory compliance note into the preview so the agent response // and the persisted preview_data (rendered by the /pending approval card) // carry the same warning. if (options.complianceNote) { previewData = { ...previewData, compliance_warning: options.complianceNote } } // 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.${options.complianceNote ? ` WARNING: ${options.complianceNote}` : ''}`, 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: options.idempotencyParams ?? 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.${options.complianceNote ? ` WARNING: ${options.complianceNote}` : ''}`, 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)) } // ── Skattekonto row booking (stage side) ───────────────────── // // Shape of the skattekonto_transactions columns the book_skattekonto_row(s) // tools select at stage time (both call sites keep the select string literal // so the no-phantom-columns scanner can resolve it): enough for the reviewer // preview + the same bookability gates the commit path enforces // (requireSettled batch booking in skatteverket/lib/skattekonto-booking.ts). interface SkattekontoStageRow { id: string transaktionsdatum: string transaktionstext: string belopp_skatteverket: number | string status: string is_ignored: boolean | null journal_entry_id: string | null } /** * 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 (UUID_RE.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 dbError(error, `Database error resolving voucher "${series}-${number}"`) } 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, // Explicit business-side account replacing the category default (v1 REST // account_override semantics): must exist active in chart_of_accounts. accountOverride: string | 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, transaction.currency, ) mappingResult = applySettlementAccount(mappingResult, settlementAccount) // Applied at staging so the agent gets the tight rejection (unknown or // inactive account) BEFORE anything is queued for approval, and the staged // preview shows the account that will actually be posted. The commit path // (categorizeMatchedTransaction) re-validates independently. if (accountOverride) { if (!isBusiness) { throw new Error('account_override kan inte kombineras med category "private".') } mappingResult = await applyAccountOverride( supabase, companyId, accountOverride, transaction.amount, mappingResult, // Explicit VAT intent: a stated treatment or an underlag vat_amount. // Without it the override books gross (see applyAccountOverride). vatTreatment != null || vatAmount != null, ) } 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, } } /** * A voucher line that names neither debit_amount nor credit_amount is a shape * error, not a zero line. `Number(undefined) || 0` silently turned * {debit: 500}, {amount, side} and {debitAmount: 500} into 0/0, and the * balance check then reported "debits 0 SEK, credits 0 SEK" for four * perfectly balanced formats (feedback seq 318571). No host validates * inputSchema at runtime, so the guard lives here, and it names the keys it * got so the agent can fix the shape in one turn instead of guessing at the * amounts. */ function assertVoucherLineShape(line: Record, label: string): void { const hasDebit = line.debit_amount !== undefined && line.debit_amount !== null const hasCredit = line.credit_amount !== undefined && line.credit_amount !== null if (!hasDebit && !hasCredit) { const got = Object.keys(line).filter((k) => k !== 'account_number') throw new Error( `${label}: expected debit_amount and/or credit_amount (numbers in SEK)` + (got.length ? `; got ${got.join(', ')}` : '; got neither') + '. Example: {"account_number":"6110","debit_amount":400}, {"account_number":"1930","credit_amount":400}.', ) } for (const key of ['debit_amount', 'credit_amount'] as const) { const v = line[key] if (v !== undefined && v !== null && !Number.isFinite(Number(v))) { throw new Error(`${label}.${key} must be a number in SEK; got ${JSON.stringify(v)}`) } } } // ── 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 // Declared loosely on purpose. Every field here is transmitted once per // staged-write tool, and there are 58 of them in the default catalog, so a // character in this constant costs 58 characters of every agent's context. // `additionalProperties: false` stays: staging.test.ts pins the next hint as a // closed shape, and a guard whose reason is not in front of me is not a guard // to loosen for 420 tokens. Only args' redundant `additionalProperties: true` // went, which is the JSON Schema default anyway. const NEXT_ACTION_HINT_SCHEMA = { type: 'object', additionalProperties: false, properties: { description: { type: 'string' }, tool: { type: 'string' }, args: { type: 'object' }, resource: { type: 'string' }, }, required: ['description'], } as const const STAGED_OPERATION_SCHEMA = { type: 'object', properties: { staged: { type: 'boolean' }, operation_id: { type: 'string', description: 'Staged operation UUID, 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' }, // Shape carried in prose rather than declared: the same three properties // spelled out as JSON Schema cost 58x what one sentence costs, and the // model reads the sentence either way. Same reason actor/approve/preview // above have always been bare objects. period_status: { type: 'object', description: 'Period of the affärshändelse: period_id, status (open|locked|closed), lock_date. Detects a locked period without a round-trip.', }, 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_post_kontantmetod_cutoff: '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', gnubok_reconcile_match: 'gnubok_get_reconciliation_status', gnubok_reconcile_signoff: 'gnubok_get_reconciliation_status', gnubok_reconcile_residual: 'gnubok_get_reconciliation_status', } /** * 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' } /** * Inline SIE content above this length is refused: a model reproducing tens * of thousands of tokens verbatim WILL eventually truncate mid-#VER, and a * truncated file that still parses imports silently incomplete bookkeeping. * Larger files go through gnubok_create_sie_upload (raw bytes, no model in * the path). ~120k chars ≈ a few thousand verifikat rows. */ const MAX_INLINE_SIE_CHARS = 120_000 /** * Resolve SIE file content from tool args, three sources in priority order: * * - upload_id: raw bytes PUT to a gnubok_create_sie_upload URL. The only * path with no model in the loop; required for large files. * - file_content_base64: exact bytes base64-encoded (e.g. from a * code-execution sandbox). * - file_content: plain text as the model read the attachment. * * Byte paths run the same encoding detection as the HTTP upload route, so * CP437 exports keep their åäö. An optional sha256 (hex, of the RAW BYTES) * is verified on the two byte-exact paths and proves the content was not * truncated or altered in transit; it cannot be checked for plain text * (the model's read may legitimately differ from the file's bytes). * Returns null when no source is usable. */ async function resolveSieToolContent( args: Record, companyId: string, userId: string ): Promise { const sha256 = typeof args.sha256 === 'string' ? args.sha256.trim().toLowerCase() : null const verifyBytes = (buffer: Buffer, source: string) => { if (!sha256) return const actual = createHash('sha256').update(buffer).digest('hex') if (actual !== sha256) { throw codedError( 'VALIDATION_ERROR', `sha256 mismatch on ${source}: expected ${sha256}, got ${actual}. The content was truncated or altered; re-send it.` ) } } const { detectEncoding, decodeBuffer } = await import('@/lib/import/sie-parser') if (typeof args.upload_id === 'string' && args.upload_id.length > 0) { const fileName = typeof args.filename === 'string' ? args.filename : '' if (!fileName) { throw codedError('VALIDATION_ERROR', 'filename is required together with upload_id') } const service = createServiceClientNoCookies() const pendingPath = buildPendingDocumentStoragePath(companyId, userId, args.upload_id, fileName) const { data, error } = await service.storage.from(DOCUMENTS_BUCKET).download(pendingPath) if (error || !data) { throw codedError( 'NOT_FOUND', 'No uploaded file found for this upload_id. PUT the raw file bytes to the upload_url from gnubok_create_sie_upload first (same upload_id and filename).' ) } const buffer = Buffer.from(await data.arrayBuffer()) verifyBytes(buffer, 'the uploaded file') const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer)) } if (typeof args.file_content_base64 === 'string' && args.file_content_base64.length > 0) { // The inline cap exists to stop a MODEL from retyping a large file with // silent truncation. A caller that provides sha256 (the drop-card widget // always does) has byte-exact content and the hash check below IS the // truncation guard, so the cap does not apply. if (!sha256 && args.file_content_base64.length > MAX_INLINE_SIE_CHARS) { throw codedError( 'VALIDATION_ERROR', 'File too large to pass inline safely without a sha256. Use gnubok_create_sie_upload (drag-and-drop card / PUT to its upload_url) and pass the upload_id here, or include sha256 of the raw bytes.' ) } const buffer = Buffer.from(args.file_content_base64, 'base64') verifyBytes(buffer, 'file_content_base64') const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) return decodeBuffer(arrayBuffer, detectEncoding(arrayBuffer)) } if (typeof args.file_content === 'string' && args.file_content.length > 0) { if (args.file_content.length > MAX_INLINE_SIE_CHARS) { throw codedError( 'VALIDATION_ERROR', 'File too large to pass inline safely (silent mid-verifikat truncation risk). Use gnubok_create_sie_upload, PUT the raw bytes to its upload_url, and pass the upload_id here instead.' ) } return args.file_content } return null } /** * Absolute origin for links the user opens in a browser. A deployment * without NEXT_PUBLIC_APP_URL falls back to localhost in getCanonicalBaseUrl, * which would hand a remote user an unusable link: refuse instead. */ function connectLinkBaseUrl(): string { if (!process.env.NEXT_PUBLIC_APP_URL) { throw codedError( 'INTERNAL_ERROR', 'NEXT_PUBLIC_APP_URL is not configured on this installation; cannot build a connect link' ) } return getCanonicalBaseUrl() } /** * The team a company created through the API/MCP path attaches to when the * caller does not name one: the user's PERSONAL team only (WL-08: companies * created through the normal flow always attach to the personal team; cockpit * flows pass the byrå team id explicitly). Picking the first membership * regardless of kind attached a consultant's private company to their byrå * team, exposing their books to the whole byrå and suppressing the trial. * Mirrors ensure_user_team: earliest teams row with kind='personal'. null * when the user has no personal team; create_company_for_user then leaves * team_id NULL. Explicit team_id args are authorized by the DB gate instead. */ async function defaultTeamForUser(supabase: SupabaseClient, userId: string): Promise { const { data, error } = await supabase .from('team_members') .select('team_id, teams!inner(kind, created_at)') .eq('user_id', userId) .eq('teams.kind', 'personal') .order('teams(created_at)', { ascending: true }) .order('teams(id)', { ascending: true }) .limit(1) .maybeSingle() if (error) { log.warn('default team lookup failed', { error: error.message }) return null } return (data?.team_id as string | undefined) ?? null } 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. // Keep the MCP report's historical ruta 05 widening for accounts that do not // have an explicit treatment. The effective resolver adds custom ruta 05 // accounts and removes any of these when the company overrides their treatment. const RUTA_05_COMPATIBILITY_ACCOUNTS = [ '3000', '3001', '3002', '3003', '3005', '3006', '3007', '3008', '3106', '3041', '3042', '3043', '3044', '3045', '3046', '3047', '3048', '3051', '3052', '3053', '3054', '3055', '3056', '3057', '3058', '3071', '3072', '3073', '3074', '3075', '3076', '3077', '3078', ] as const 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[] } interface VatReportWithRutor { report: VatReportResult dynamicVatAccounts: Awaited> /** * 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 } 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') const { start: startDate, end: endDate } = await resolvePeriodDates( supabase, companyId, periodType as 'monthly' | 'quarterly' | 'yearly', year, period, ) // 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 debitBalance(acc: string): number { const t = accountTotals.get(acc) return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0 } function creditBalance(acc: string): number { return -debitBalance(acc) } const dynamicVatAccounts = await fetchDynamicVatAccounts(supabase, companyId) const declarationRutor = rutorFromTotals(accountTotals, dynamicVatAccounts) const { ruta10, ruta11, ruta12, ruta30, ruta31, ruta32, ruta35, ruta39, ruta40, ruta48, ruta49, ruta60, ruta61, ruta62, } = declarationRutor const reportRuta05Accounts = new Set( RUTA_05_COMPATIBILITY_ACCOUNTS.filter( (account) => !dynamicVatAccounts.explicitAccounts.has(account), ), ) for (const [account, mapping] of dynamicVatAccounts.mappingByAccount) { if (mapping.box === 'ruta05') reportRuta05Accounts.add(account) } const ruta05 = [...reportRuta05Accounts] .reduce((sum, account) => sum + creditBalance(account), 0) // 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 calculatedInput2645 = debitBalance('2645') const calculatedInput2647 = debitBalance('2647') 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, dynamicVatAccounts, 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, dynamicVatAccounts?: Awaited>, rcBasisByRate?: { r25: number; r12: number; r6: number }, ): Promise { let scan: RcBasisGapScan try { const gaps = await findRcBasisGaps(supabase, companyId, periodType, year, period) scan = { status: 'scanned', gapCount: gaps.length } } catch { scan = { status: 'unavailable' } } // Downgrade evidence (per-momssats 44xx/45xx balances) only exists when the // caller supplied the account totals; without them the per-voucher gaps // keep their blocking ERROR tier rather than guessing. const evidence = accountTotals ? { rutor, rcBasisByRate: rcBasisByRate ?? rcBasisTotalsByRate(accountTotals, dynamicVatAccounts), } : undefined return withRcBasisGapFindings(runVatDeclarationChecks(rutor, accountTotals), scan, evidence) } /** 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' | 'deadline_unavailable' 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'] } /** * Hint for the uncategorized_transactions blocker. Must name BOTH resolution * paths: categorize/auto-match creates NEW bookkeeping, so for a transaction * whose affärshändelse is already booked on an existing verifikat the agent * needs gnubok_link_transaction_to_journal_entry instead; a hint that only * offers the booking tools dead-ends that case into "contact support" * (2026-08-06 support case). Exported so the test can pin the contract. */ export const UNCATEGORIZED_TRANSACTIONS_HINT = 'Kategorisera via gnubok_categorize_transaction eller kör gnubok_auto_match_period. ' + 'Är affärshändelsen redan bokförd på ett befintligt verifikat: koppla i stället med ' + 'gnubok_link_transaction_to_journal_entry (ingen ny bokföring skapas).' /** * 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', ]) /** * gnubok_year_end_readiness: YearEndBlockerCode to the blocker `kind` this * tool publishes. The kinds are the public contract agents switch on, so they * are deliberately NOT the codes themselves: a code may be renamed or split * without breaking a consumer, as long as it keeps mapping to the same kind. * * UNBOOKED_CHECK_FAILED shares 'unbooked_transactions' with the real count: * the fail-closed variant means "we could not tell", and an agent should react * to it the same way (go look at the transactions, then re-run readiness). * * Exported so the tool-description test can assert that every kind an agent * can receive is actually named in the description it plans against: the * description drifted once already (it advertised FX revaluation, a WARNING, * as a blocker and never mentioned unbooked transactions, the common one). */ export const YEAR_END_BLOCKER_KIND: Record = { PERIOD_NOT_FOUND: 'period_not_found', PERIOD_NOT_ENDED: 'period_not_ended', PERIOD_ALREADY_CLOSED: 'period_already_closed', CLOSING_ENTRY_EXISTS: 'closing_entry_exists', DRAFT_ENTRIES: 'draft_entries', UNEXPLAINED_VOUCHER_GAP: 'unexplained_voucher_gap', SEQUENCE_COUNTER_BEHIND: 'sequence_mismatch', TRIAL_BALANCE_UNBALANCED: 'trial_balance_unbalanced', CONTINUITY_MISMATCH: 'opening_balance_continuity', NEXT_PERIOD_HAS_IB: 'next_period_ib_posted', KONTANTMETOD_CUTOFF_REQUIRED: 'kontantmetod_cutoff_required', KONTANTMETOD_CUTOFF_CHECK_FAILED: 'kontantmetod_cutoff_required', UNBOOKED_TRANSACTIONS: 'unbooked_transactions', UNBOOKED_CHECK_FAILED: 'unbooked_transactions', } /** * Wording fallback for a blocker whose code is not in YEAR_END_BLOCKER_KIND. * Kept so an unmapped or legacy English message still routes somewhere useful * instead of collapsing to 'other'. */ function classifyYearEndBlockerMessage(message: string): string { if (/draft journal entries|utkast måste bokföras/i.test(message)) return 'draft_entries' if (/unbooked transaction|saknar bokföring|obokförda transaktioner/i.test(message)) return 'unbooked_transactions' if (/voucher gap|verifikationsnummerglapp/i.test(message)) return 'unexplained_voucher_gap' if (/Sequence counter integrity|Nummerserien i serie/i.test(message)) return 'sequence_mismatch' if (/Trial balance is not balanced|Råbalansen balanserar inte/i.test(message)) return 'trial_balance_unbalanced' if (/already closed|redan stängd/i.test(message)) return 'period_already_closed' if (/has not yet ended|slutdatumet har inte passerat/i.test(message)) return 'period_not_ended' if (/closing entry already exists|Bokslutsverifikation finns redan/i.test(message)) return 'closing_entry_exists' if (/continuity check failed|IB\/UB-kontinuiteten/i.test(message)) return 'opening_balance_continuity' if (/opening balances already posted|redan ingående balanser bokförda/i.test(message)) return 'next_period_ib_posted' if (/Fiscal period not found|Räkenskapsperioden hittades inte/i.test(message)) return 'period_not_found' return 'other' } 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 } /** Adapt the canonical VAT deadline configuration to the MCP wire shape. */ export function computeMomsDeadline( periodType: 'monthly' | 'quarterly' | 'yearly', year: number, period: number, settings: VatDeadlineCalculationSettings, ): { date: string; label: string } | null { const instance = getVatDeadlineForPeriod(periodType, year, period, settings) if (!instance) return null const adjusted = adjustDeadlineToNextBankingDay( new Date(instance.year, instance.month, instance.day), ) const deadlineYear = adjusted.getFullYear() const deadlineMonth = adjusted.getMonth() + 1 const deadlineDay = adjusted.getDate() return { date: `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-${String(deadlineDay).padStart(2, '0')}`, label: `${deadlineDay} ${monthName(deadlineMonth)} ${deadlineYear}`, } } 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 /** 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, nextDay(end)), ]) return Math.max(0, fromStart - afterEnd) } /** * Resolve the fiscal period a report tool runs against: the caller's * `period_id` when given, else the company's most recent period. The period * is then re-read scoped to the company, so a foreign id never resolves. */ async function resolveReportPeriod( supabase: SupabaseClient, companyId: string, periodIdArg: unknown, noPeriodsMessage: string, ) { let periodId = periodIdArg 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(noPeriodsMessage) 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.') return period } /** Invoice row plus its customer, scoped to the company; throws when absent. */ async function fetchInvoiceWithCustomer(supabase: SupabaseClient, companyId: string, invoiceId: string) { 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') return invoice } /** Offset-pagination tail shared by the list tools: count/total/has_more/next_offset. */ function pageTail(rows: unknown[], total: number, offset: number) { const hasMore = offset + rows.length < total return { count: rows.length, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + rows.length } : {}), } } /** * 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, dynamicVatAccounts, accountTotals } = await computeVatReportWithRutor(args, companyId, supabase) const { start, end, type: periodType, year, period } = vatReport.period // 2) Company settings: deadline inputs come from the same fields used by // lib/tax/deadline-config.ts. The over-40M flag changes monthly filers // from the 12th/17th M+2 schedule to the 26th M+1 schedule. const { data: settings, error: settingsError } = await supabase .from('company_settings') .select('moms_period, vat_taxable_base_over_40m, entity_type, fiscal_year_start_month, vat_has_eu_trade, vat_filing_method') .eq('company_id', companyId) .single() const momsPeriod = (settings?.moms_period as 'monthly' | 'quarterly' | 'yearly' | null) ?? null const entityType = settings?.entity_type === 'aktiebolag' || settings?.entity_type === 'enskild_firma' ? settings.entity_type : 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. // Never turn missing settings into a plausible statutory date. Monthly // deadlines need the turnover threshold; annual deadlines additionally // need the filing profile and a configured fiscal year matching the // report range. Quarterly dates are independent of company settings. let deadline: { date: string; label: string } | null = null if (periodType === 'quarterly') { deadline = computeMomsDeadline('quarterly', Number(year), Number(period), { vat_taxable_base_over_40m: false, }) } else if ( periodType === 'monthly' && typeof settings?.vat_taxable_base_over_40m === 'boolean' ) { deadline = computeMomsDeadline('monthly', Number(year), Number(period), { vat_taxable_base_over_40m: settings.vat_taxable_base_over_40m, }) } else if (periodType === 'yearly' && settings && entityType) { const configuredStartMonth = typeof settings.fiscal_year_start_month === 'number' && settings.fiscal_year_start_month >= 1 && settings.fiscal_year_start_month <= 12 ? settings.fiscal_year_start_month : null const reportEndMonth = Number(end.slice(5, 7)) const reportStartMonth = reportEndMonth === 12 ? 1 : reportEndMonth + 1 const fiscalYearMatches = entityType === 'enskild_firma' ? reportEndMonth === 12 : configuredStartMonth === reportStartMonth const filingMethodRequired = entityType === 'aktiebolag' && settings.vat_has_eu_trade === false const filingProfileComplete = typeof settings.vat_has_eu_trade === 'boolean' && (!filingMethodRequired || settings.vat_filing_method === 'electronic' || settings.vat_filing_method === 'paper') if (fiscalYearMatches && filingProfileComplete) { const deadlineSettings: VatDeadlineCalculationSettings = { vat_taxable_base_over_40m: settings.vat_taxable_base_over_40m === true, entity_type: entityType, fiscal_year_start_month: reportStartMonth, vat_has_eu_trade: settings.vat_has_eu_trade, vat_filing_method: settings.vat_filing_method, } deadline = computeMomsDeadline('yearly', Number(year), Number(period), deadlineSettings) } } if (!deadline) { log.warn('VAT deadline unavailable', { companyId, periodType, hasSettings: Boolean(settings), settingsErrorCode: settingsError?.code, }) } // 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[] = [] if (!deadline) { blockers.push({ kind: 'deadline_unavailable', severity: 'high', count: 1, message: 'Momsens inlämningsdatum kunde inte fastställas säkert', hint: 'Kontrollera momsinställningar, deklarationssätt och räkenskapsperiod innan deklarationen lämnas in.', }) } const uncategorizedCount = uncategorizedRes.count ?? 0 if (uncategorizedCount > 0) { blockers.push({ kind: 'uncategorized_transactions', severity: 'high', count: uncategorizedCount, message: `${uncategorizedCount} okategoriserade banktransaktioner i perioden`, hint: UNCATEGORIZED_TRANSACTIONS_HINT, }) } 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, dynamicVatAccounts, ) // 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 // Optional custom date range on the report tools. Historically from_date / // to_date were silently dropped (the inputSchema said additionalProperties: // false but nothing enforced it), so an agent asking for January-July got the // full fiscal year back with no signal. Validate loudly instead. function parseReportRangeArgs( args: Record, period: { period_start: string; period_end: string }, keys: { from?: string; to: string }, ): { fromDate?: string; toDate?: string } { const rawFrom = keys.from ? (args[keys.from] as string | undefined) : undefined const rawTo = args[keys.to] as string | undefined for (const [key, value] of [[keys.from, rawFrom], [keys.to, rawTo]] as const) { if (value === undefined || key === undefined) continue if (typeof value !== 'string' || !ISO_DATE_RE.test(value)) { throw new Error(`${key} must be an ISO date (YYYY-MM-DD).`) } if (value < period.period_start || value > period.period_end) { throw new Error( `${key} must be within the fiscal period (${period.period_start} to ${period.period_end}). ` + `For another year, pass that year's period_id.`, ) } } if (rawFrom && rawTo && rawFrom > rawTo) { throw new Error(`${keys.from} must not be after ${keys.to}.`) } return { fromDate: rawFrom, toDate: rawTo } } // Reject unknown args on tools that opt in, instead of silently ignoring // them: a misspelled parameter (fromdate=) must not degrade to a full-period // report the agent mistakes for the range it asked for. function rejectUnknownArgs(args: Record, allowed: readonly string[]): void { const unknown = Object.keys(args).filter((k) => !allowed.includes(k)) if (unknown.length > 0) { throw new Error( `Unknown parameter(s): ${unknown.join(', ')}. Allowed: ${allowed.join(', ')}. ` + `Unknown parameters are rejected rather than silently ignored.`, ) } } // 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()) } // ── Kundorder (sales orders) helpers ───────────────────────── const SALES_ORDER_STATUSES = ['draft', 'confirmed', 'completed', 'cancelled'] as const const SALES_ORDER_PROGRESS = ['none', 'partial', 'full'] as const /** * Staging-time mirror of the header state machine in * lib/sales-orders/transitions.ts. The service is authoritative at commit * (it re-reads the order and compare-and-sets on the status it saw); this * copy exists only so an impossible transition is refused before it costs * an approval round-trip. */ const SALES_ORDER_TRANSITIONS: Record< 'confirm' | 'cancel' | 'reopen', { from: readonly SalesOrderStatus[]; to: SalesOrderStatus } > = { confirm: { from: ['draft'], to: 'confirmed' }, cancel: { from: ['draft', 'confirmed'], to: 'cancelled' }, reopen: { from: ['cancelled'], to: 'draft' }, } /** Surface a lib/sales-orders ServiceFailure through the tool error envelope. */ function throwSalesOrderFailure(failure: ServiceFailure, context: string): never { if ('dbError' in failure) throw dbError(failure.dbError) const entry = getErrorEntry(failure.code) const details = failure.details ? ` ${JSON.stringify(failure.details)}` : '' throw new Error(`${context}: ${failure.code}. ${entry?.message_en ?? ''}${details}`.trim()) } async function loadSalesOrderOrThrow( supabase: SupabaseClient, companyId: string, rawId: unknown, ): Promise { const orderId = String(rawId ?? '').trim() if (!orderId) throw new Error('sales_order_id is required. Use gnubok_list_sales_orders to find IDs.') const res = await loadSalesOrder(supabase, companyId, orderId) if (!res.ok) { if ('code' in res && res.code === 'SALES_ORDER_NOT_FOUND') { throw new Error('Sales order not found. Use gnubok_list_sales_orders to find valid IDs.') } throwSalesOrderFailure(res, 'Could not load sales order') } return res.order } /** First Zod issue of a staged sales-order payload as a tool error. */ function throwFirstZodIssue(result: { success: false; error: z.ZodError }, field?: string): never { const issue = result.error.issues[0] const path = [field, ...(issue?.path ?? [])].filter((p) => p !== undefined && p !== '').join('.') throw new Error(`Invalid ${path || 'arguments'}: ${issue?.message ?? 'validation failed'}`) } function salesOrderSummary(order: SalesOrder) { return { sales_order_id: order.id, order_number: order.order_number ?? null, status: order.status, customer_id: order.customer_id ?? null, customer_name: (order.customer as { name?: string } | null | undefined)?.name ?? null, order_date: order.order_date, requested_delivery_date: order.requested_delivery_date ?? null, last_delivery_date: order.last_delivery_date ?? null, currency: order.currency, subtotal: order.subtotal, vat_amount: order.vat_amount, total: order.total, delivery_progress: order.delivery_progress ?? 'none', invoicing_progress: order.invoicing_progress ?? 'none', line_count: (order.items ?? []).length, } } function salesOrderLineOut(item: SalesOrderItem) { return { sales_order_item_id: item.id, line_type: item.line_type, description: item.description, quantity: item.quantity, delivered_qty: item.delivered_qty, invoiced_qty: item.invoiced_qty ?? 0, remaining_qty: item.remaining_qty ?? Math.max(0, item.quantity - (item.invoiced_qty ?? 0)), unit: item.unit, unit_price: item.unit_price, discount_percent: item.discount_percent, line_total: item.line_total, vat_rate: item.vat_rate, article_id: item.article_id ?? null, revenue_account: item.revenue_account ?? null, dimensions: item.dimensions ?? {}, } } const SALES_ORDER_SUMMARY_PROPS = { sales_order_id: { type: 'string' }, order_number: { type: ['string', 'null'], description: 'OR-; null only if numbering failed at creation' }, status: { type: 'string', enum: SALES_ORDER_STATUSES }, customer_id: { type: ['string', 'null'] }, customer_name: { type: ['string', 'null'] }, order_date: { type: 'string' }, requested_delivery_date: { type: ['string', 'null'] }, last_delivery_date: { type: ['string', 'null'], description: 'Latest registered delivery; becomes delivery_date on invoices created from the order' }, currency: { type: 'string' }, subtotal: { type: 'number' }, vat_amount: { type: 'number' }, total: { type: 'number' }, delivery_progress: { type: 'string', enum: SALES_ORDER_PROGRESS }, invoicing_progress: { type: 'string', enum: SALES_ORDER_PROGRESS }, line_count: { type: 'number' }, } as const const SALES_ORDER_SUMMARY_REQUIRED = [ 'sales_order_id', 'status', 'order_date', 'currency', 'subtotal', 'vat_amount', 'total', 'delivery_progress', 'invoicing_progress', 'line_count', ] as const const SALES_ORDER_LINE_OUTPUT_SCHEMA = { type: 'object', additionalProperties: false, properties: { sales_order_item_id: { type: 'string' }, line_type: { type: 'string', description: 'product or text' }, description: { type: 'string' }, quantity: { type: 'number' }, delivered_qty: { type: 'number', description: 'Cumulative delivered quantity registered so far' }, invoiced_qty: { type: 'number', description: 'Derived from linked lines on non-cancelled, non-credited invoices' }, remaining_qty: { type: 'number', description: 'quantity - invoiced_qty, never negative' }, unit: { type: 'string' }, unit_price: { type: 'number' }, discount_percent: { type: 'number', description: 'Line discount 0-100; line_total is net of it' }, line_total: { type: 'number', description: 'Net of discount, order currency' }, vat_rate: { type: 'number' }, article_id: { type: ['string', 'null'] }, revenue_account: { type: ['string', 'null'] }, dimensions: { type: 'object', additionalProperties: { type: 'string' } }, }, required: [ 'sales_order_item_id', 'line_type', 'description', 'quantity', 'delivered_qty', 'invoiced_qty', 'remaining_qty', 'unit', 'unit_price', 'line_total', 'vat_rate', ], } as const const SALES_ORDER_LINE_INPUT_SCHEMA = { type: 'object', properties: { description: { type: 'string' }, quantity: { type: 'number' }, unit: { type: 'string', description: 'st, tim, dag, mån' }, unit_price: { type: 'number', description: 'Per unit excl. VAT' }, discount_percent: { type: 'number', description: 'Line discount 0-100' }, vat_rate: { type: 'number', description: 'VAT rate 0-100; default from customer VAT rules' }, article_id: { type: 'string', description: 'Article UUID; prefills like gnubok_create_invoice, line values win' }, line_type: { type: 'string', enum: ['product', 'text'], description: 'text = free-text row, no amounts' }, revenue_account: { type: ['string', 'null'], description: 'BAS class 1-3 account override' }, dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn}' }, }, required: ['quantity'], } as const // ── Tools ──────────────────────────────────────────────────── export const tools: McpTool[] = [ { // The bridge that makes `catalogVisibility: 'search'` usable on hosts that // can only invoke what tools/list showed them. // // Server-side, every tool has always been callable: the tools/call // dispatcher resolves the name against the whole `tools` array and // `isDefaultCatalogTool` gates only what tools/list SHOWS. The failure was // purely client-side (Claude.ai cannot name a tool it never saw), which is // why search-only tools shipped unreachable there. // // This tool is never executed. `tools/call` rewrites a // gnubok_call_tool({tool, arguments}) request into a direct call on the // inner tool BEFORE resolution, so scope checks, the unknown-argument // guard, company routing, the staging contract and telemetry all apply to // the real target rather than to a wrapper that would have bypassed them. name: 'gnubok_call_tool', title: 'Call a Read Tool by Name', description: 'Invoke any read-only tool by name, including ones absent from tools/list. Find the name with gnubok_search_tools first. Writes are refused: call a write tool directly so its approval contract stays visible.', inputSchema: { type: 'object', additionalProperties: false, properties: { tool: { type: 'string', description: 'Canonical name of the read-only tool to invoke, e.g. "gnubok_get_reconciliation_status".', }, arguments: { type: 'object', description: "Arguments for that tool, validated against its own inputSchema. Omit for a tool that takes none.", }, }, required: ['tool'], }, // The response is whatever the inner tool returns, so no fixed shape can // be declared. Every tool must carry an object outputSchema, and an open // object is the only honest one here. outputSchema: { type: 'object', additionalProperties: true, description: 'The inner tool\'s own result, unchanged.', }, annotations: ANNOTATIONS_READ_ONLY, async execute() { // Unreachable: the dispatcher rewrites the call before resolution. If // this ever throws, the rewrite was removed and every call would have // silently skipped the read-only check. throw codedError( 'VALIDATION_ERROR', 'gnubok_call_tool is resolved by the dispatcher and has no direct implementation.', ) }, }, { 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).' }, }, // No offset exists: a caller paged with one today (2026-09-01) and was // rejected. Raise limit instead, or narrow the query. examples: [{ query: 'moms', detail: 'summary' }, { query: 'faktura', limit: 50 }], }, 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: ANNOTATIONS_READ_ONLY, 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, description // or keywords (for a single-word query this is identical to a literal // substring match). Keywords are matcher-side synonyms (mainly Swedish // domain terms) declared on the tool definition; they are searchable but // never serialized into any payload. 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 or // exact keyword > per-term name/keyword 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 kw = (t.keywords ?? []).map((k) => k.toLowerCase()) const kwText = kw.join(' ') const hay = `${name} ${desc} ${kwText}` 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 if (kw.includes(query)) score += 40 for (const term of terms) { if (name.includes(term)) score += 10 if (kwText.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', keywords: ['företag', 'bolag', 'mina företag'], 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: ANNOTATIONS_READ_ONLY, 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_lookup_company', keywords: ['organisationsnummer', 'företag', 'bolag'], title: 'Look Up Company', description: 'Look up a Swedish company by organisationsnummer in the public registry (name, address, F-skatt, VAT, legal form, fiscal year). Call FIRST in onboarding: the user confirms facts instead of answering questions. Feeds gnubok_create_company; works before any company exists.', inputSchema: { type: 'object', additionalProperties: false, properties: { org_number: { type: 'string', description: '10 digits (personnummer for enskild firma); hyphens/spaces OK', }, }, required: ['org_number'], }, outputSchema: { type: 'object', properties: { status: { type: 'string', enum: ['found', 'not_found', 'unavailable'] }, company: { type: ['object', 'null'] }, suggested_create_company_input: { type: ['object', 'null'] }, still_to_ask: { type: 'array', items: { type: 'string' } }, warnings: { type: 'array', items: { type: 'string' } }, instructions: { type: 'string' }, }, required: ['status', 'company', 'suggested_create_company_input', 'still_to_ask', 'warnings', 'instructions'], }, annotations: ANNOTATIONS_READ_ONLY_OPEN_WORLD, async execute(args) { const raw = String((args as { org_number: string }).org_number ?? '') const normalized = normalizeOrgNumber(raw) if (!normalized) { throw codedError( 'VALIDATION_ERROR', 'Invalid organisationsnummer: expected 10 digits (hyphens and spaces are OK).' ) } const askEverything = [ 'name', 'entity_type (enskild firma or aktiebolag)', 'f_skatt', 'vat_registered (and moms_period if yes)', 'accounting_method (accrual or cash)', 'fiscal year', 'address (optional)', ] let lookup try { lookup = await lookupCompanyByOrgNumber(normalized) } catch (error) { if (error instanceof TICAPIError) { return { status: 'unavailable', company: null, suggested_create_company_input: null, still_to_ask: askEverything, warnings: [`Registry lookup unavailable (${error.code}).`], instructions: 'The registry lookup is unavailable right now. Fall back to asking the user each question in still_to_ask, then call gnubok_create_company (preview first, then confirm=true).', } } throw error } if (!lookup) { return { status: 'not_found', company: null, suggested_create_company_input: null, still_to_ask: askEverything, warnings: [], instructions: 'No company matched this organisationsnummer. Double-check the number with the user; a brand-new registration can take days to appear. If the number is right, ask each question in still_to_ask and call gnubok_create_company manually.', } } const entityType = mapEntityType(lookup.legalEntityType) const warnings: string[] = [] if (lookup.isCeased) { warnings.push( 'The registry marks this company as CEASED (avregistrerat). Surface this to the user before continuing; they may still proceed.' ) } if (!entityType) { warnings.push( `Legal form "${lookup.legalEntityType ?? 'unknown'}" is not supported for automatic setup: only enskild firma and aktiebolag can be created here.` ) } // Mirror the web onboarding journey's fact-vs-question rules // (lib/onboarding-journey/reducer.ts): facts from a successful lookup // are presented for confirmation, not asked. F-skatt is a fact both // ways; VAT is a fact ONLY when positively registered (ML 17 kap 24 // paragraf: never silently default vat_registered); moms period and // accounting method are ALWAYS the user's answer. const vatIsFact = lookup.registration.vat === true const stillToAsk: string[] = [] if (!entityType) stillToAsk.push('entity_type (enskild firma or aktiebolag)') if (entityType === 'enskild_firma') { stillToAsk.push( 'name: for enskild firma the verksamhetsnamn is freely choosable; suggest the registered name but let the user pick' ) } if (!vatIsFact) stillToAsk.push('vat_registered (the registry shows no VAT registration; confirm with the user)') if (vatIsFact) stillToAsk.push('moms_period (monthly, quarterly or yearly; never guess)') else stillToAsk.push('moms_period IF vat_registered turns out true') // accounting_method is deliberately NOT in this list: gnubok_create_company // defaults it by form (AB accrual, EF cash) and flags the default in the // preview, where the user confirms or overrides it in the same "ja". // // Two flow questions the agent must ask in the SAME round (E2E #5: the // agent skipped both and the user landed on a generic bank picker with // no history import): stillToAsk.push( "bank (vilken bank har företaget? pass it to gnubok_connect_bank as bank so the connect link opens that bank's consent directly)" ) stillToAsk.push( 'previous_system (har bokföringen legat i ett annat system? if yes: SIE import comes FIRST, before any bank connect: PSD2 history rarely covers the fiscal year)' ) const startMonth = parseStartMonthDay(lookup.fiscalYear?.startMonthDay) // No closed fiscal period in the registry = no annual report filed yet // = still in the first räkenskapsår, which BFL 3 kap 3 § lets run up to // 18 months. The 12-month window alone misses extended first years. const firstYear = deriveFirstYearDefaults(lookup.registrationDate, Date.now(), { noClosedPeriod: lookup.fiscalYear == null, }) const registrationIso = lookup.registrationDate ? new Date(lookup.registrationDate).toISOString().slice(0, 10) : null if (startMonth !== null) { stillToAsk.push( `fiscal year: registry shows ${lookup.fiscalYear?.startMonthDay} to ${lookup.fiscalYear?.endMonthDay}; ask "stämmer detta?" instead of an open question` ) } else if (firstYear.isFirstFiscalYear) { stillToAsk.push( `fiscal year: no closed period in the registry, so this is likely the FIRST räkenskapsår; suggest first_fiscal_year start ${registrationIso ?? firstYear.firstYearStart} (registration date). End: an enskild firma MUST end 31 December; an AB may pick ANY end within 18 months of start (BFL 3 kap 3 §), 31 December is merely the common default. Ask "stämmer det?" with the choice visible` ) } else { stillToAsk.push('fiscal year: calendar year or broken year (no registry data)') } const suggested: Record = { name: lookup.companyName || undefined, entity_type: entityType ?? undefined, org_number: normalized, f_skatt: lookup.registration.fTax, ...(vatIsFact ? { vat_registered: true } : {}), ...(lookup.address?.street ? { address_line1: lookup.address.street } : {}), ...(lookup.address?.postalCode ? { postal_code: lookup.address.postalCode } : {}), ...(lookup.address?.city ? { city: lookup.address.city } : {}), ...(startMonth !== null ? { fiscal_year_start_month: startMonth } : {}), } return { status: 'found', company: { name: lookup.companyName, org_number: normalized, legal_entity_type: lookup.legalEntityType, is_ceased: lookup.isCeased, address: lookup.address, f_skatt: lookup.registration.fTax, vat_registered: lookup.registration.vat, fiscal_year: lookup.fiscalYear ?? null, registration_date: lookup.registrationDate ? new Date(lookup.registrationDate).toISOString().slice(0, 10) : null, sni_codes: lookup.sniCodes, }, suggested_create_company_input: suggested, still_to_ask: stillToAsk, warnings, instructions: 'Present the company facts as a short summary for the user to CONFIRM (name, address, F-skatt, VAT status; do not re-ask them). An established company with F-skatt/VAT is the NORMAL case even when the user says "nytt bolag" (new = new to Accounted): never refuse or second-guess the orgnr because the company looks established. Ask ONLY the still_to_ask questions, merge answers into suggested_create_company_input, and call gnubok_create_company (preview, read back incl. the defaulted accounting method, then confirm=true).', } }, }, { name: 'gnubok_create_company', keywords: ['företag', 'bolag', 'nytt företag'], title: 'Create Company', description: 'Create a NEW company, set up for bookkeeping (chart, settings, first fiscal period, tax deadlines; 30-day trial). Ask ONLY orgnr + moms period: gnubok_lookup_company prefills the rest, accounting_method defaults by form. Preview (no confirm), read back, then confirm=true.', inputSchema: { type: 'object', additionalProperties: false, properties: { name: { type: 'string', minLength: 1, maxLength: 200 }, entity_type: { type: 'string', enum: ['enskild_firma', 'aktiebolag'] }, org_number: { type: 'string', description: '10 digits; required when VAT-registered' }, vat_registered: { type: 'boolean' }, moms_period: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Required when vat_registered' }, accounting_method: { type: 'string', enum: ['accrual', 'cash'], description: 'Omit to default by form: aktiebolag accrual, enskild firma cash; the preview flags the default' }, f_skatt: { type: 'boolean' }, fiscal_year_start_month: { type: 'integer', minimum: 1, maximum: 12 }, first_fiscal_year: { type: 'object', additionalProperties: false, properties: { start: { type: 'string' }, end: { type: 'string' } }, required: ['start', 'end'], description: 'YYYY-MM-DD; first fiscal year only', }, address_line1: { type: 'string' }, postal_code: { type: 'string' }, city: { type: 'string' }, team_id: { type: 'string', format: 'uuid' }, confirm: { type: 'boolean', description: 'true creates; omitted = preview' }, }, required: ['name', 'entity_type', 'vat_registered', 'f_skatt'], }, outputSchema: { type: 'object', properties: { created: { type: 'boolean' }, requires_confirmation: { type: 'boolean' }, company_id: { type: 'string' }, preview: { type: 'object' }, next: { type: 'object' }, message: { type: 'string' }, }, }, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, _companyId, userId, supabase) { const { confirm, ...setup } = args const parsed = CompanySetupSchema.safeParse(setup) if (!parsed.success) { const detail = parsed.error.issues .map((issue) => `${issue.path.join('.') || 'input'}: ${issue.message}`) .join('; ') throw codedError('VALIDATION_ERROR', `Invalid company setup: ${detail}`) } const plan = planCompanySetup(parsed.data) if (!plan.ok) { throw codedError('VALIDATION_ERROR', `Invalid fiscal period: ${plan.error}`) } const teamId = parsed.data.team_id ?? (await defaultTeamForUser(supabase, userId)) const preview = { name: parsed.data.name, entity_type: parsed.data.entity_type, org_number: (plan.input.settings.org_number as string | null) ?? null, vat_registered: parsed.data.vat_registered, vat_number: (plan.input.settings.vat_number as string | null) ?? null, moms_period: parsed.data.vat_registered ? parsed.data.moms_period ?? null : null, accounting_method: plan.resolved.accountingMethod, // Present, never silent: the readback must name the defaulted method // so the user can override it before confirm. The cash default also // carries its eligibility condition (BFL 4 kap 4 §): the registry // cannot verify turnover, so the human must. ...(plan.resolved.accountingMethodDefaulted ? { accounting_method_defaulted: true } : {}), ...(plan.resolved.accountingMethodDefaulted && plan.resolved.accountingMethod === 'cash' ? { accounting_method_note: 'Kontantmetoden förutsätter en omsättning som normalt understiger 3 MSEK (BFL 4 kap 4 §); annars gäller faktureringsmetoden. Bekräfta detta med användaren.', } : {}), f_skatt: parsed.data.f_skatt, fiscal_period: plan.fiscalPeriod, team_id: teamId, } if (confirm !== true) { return { created: false, requires_confirmation: true, preview, message: 'Nothing was created. Read the preview back to the user (especially the fiscal period dates, the VAT setup, and the accounting method when accounting_method_defaulted is true: name the default and let them override), then call gnubok_create_company again with the same arguments and confirm=true.', } } const result = await createCompanyCore(supabase, plan.input, () => supabase.rpc('create_company_for_user', { p_user_id: userId, p_name: parsed.data.name, p_entity_type: parsed.data.entity_type, p_team_id: teamId, }) ) if (result.error !== undefined) { const code = result.error === 'org_number_invalid' ? 'VALIDATION_ERROR' : 'COMPANY_CREATE_FAILED' throw Object.assign(new Error(result.error), { code }) } // A fiscal period that started well before today means bookkeeping // already happened somewhere: history import comes BEFORE the bank // (PSD2 reaches ~90 days back; the rest only arrives via SIE). const periodStartMs = Date.parse(plan.fiscalPeriod.startDate) const daysOfHistory = Number.isFinite(periodStartMs) ? Math.floor((Date.now() - periodStartMs) / 86_400_000) : 0 const historyFirst = daysOfHistory > 90 return { created: true, company_id: result.companyId, ...preview, trial: 'A 30-day trial with every paid capability (bank sync, Skatteverket, AI, e-mail) is active from now.', ...(historyFirst ? { history_note: `The fiscal period started ${daysOfHistory} days ago but bank PSD2 history reaches ~90 days: ask which system the bookkeeping lived in and run the SIE import BEFORE connecting the bank (call gnubok_create_sie_upload to render the drag-and-drop import card).`, } : {}), message: historyFirst ? 'Company created; this connection uses it automatically from the next call. Remaining setup IN ORDER: (1) import existing bookkeeping via SIE (see history_note), (2) bank connection (pass bank=), (3) Skatteverket.' : 'Company created and ready for bookkeeping. This connection uses it automatically from the next call. Remaining setup: bank connection (pass bank= for a direct consent link), Skatteverket connection, and the first transactions.', next: { description: 'Load the onboarding skill for the remaining setup steps (history import, bank, Skatteverket, first transactions).', tool: 'gnubok_load_skill', args: { slug: 'onboarding' }, }, } }, }, { name: 'gnubok_connect_bank', keywords: ['bankkoppling', 'koppla bank', 'banksynk'], title: 'Connect Bank', description: 'Bank connection status plus the browser connect link (PSD2, BankID; user must be logged in to Accounted). When the user has NAMED their bank, pass it as bank on the FIRST call (a bare call renders a redundant generic card): the link then starts that bank\'s consent directly.', inputSchema: { type: 'object', additionalProperties: false, properties: { bank: { type: 'string', description: "The bank's name as the user said it (e.g. 'Swedbank', 'SEB'); the link auto-starts that bank's consent. Omit to show the picker.", }, }, }, outputSchema: { type: 'object', properties: { connected: { type: 'boolean' }, connections: { type: 'array', items: { type: 'object', properties: { connection_id: { type: 'string' }, bank: { type: ['string', 'null'] }, status: { type: 'string' }, since: { type: 'string' }, // Semantics live in the instructions string (runtime output), not // here: every byte of this schema is charged against the // tools/list context-budget ceiling (payload-size.bench.test.ts). last_synced_at: { type: ['string', 'null'] }, consent_expires: { type: ['string', 'null'] }, error_message: { type: ['string', 'null'] }, }, }, }, connect_url: { type: 'string' }, instructions: { type: 'string' }, }, required: ['connected', 'connections', 'connect_url', 'instructions'], }, _meta: { ui: { resourceUri: 'ui://connect-card/app.html' } }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, _userId, supabase) { const { data, error } = await supabase .from('bank_connections') .select('id, bank_name, status, created_at, last_synced_at, consent_expires, error_message') .eq('company_id', companyId) .in('status', ['pending', 'pending_selection', 'active', 'expired', 'error']) .order('created_at', { ascending: false }) if (error) throw error const connections = (data ?? []) as Array<{ id: string bank_name: string | null status: string created_at: string last_synced_at: string | null consent_expires: string | null error_message: string | null }> const active = connections.filter((c) => c.status === 'active') // A named bank deep-links straight into that bank's consent (the page // auto-starts it; unknown names fall back to the prefilled picker). const requestedBank = typeof args.bank === 'string' ? args.bank.trim() : '' const connectUrl = requestedBank ? `${connectLinkBaseUrl()}/import?mode=psd2&bank=${encodeURIComponent(requestedBank)}` : `${connectLinkBaseUrl()}/import?mode=psd2` return { connected: active.length > 0, connections: connections.map((c) => ({ connection_id: c.id, bank: c.bank_name, status: c.status, since: c.created_at, last_synced_at: c.last_synced_at, consent_expires: c.consent_expires, error_message: c.error_message, })), connect_url: connectUrl, instructions: active.length > 0 ? 'At least one bank is connected and syncing. Check last_synced_at on each connection: older than 36 hours means transactions and balances may be STALE; warn the user before building on them. A null last_synced_at right after connecting is normal (the first sync lands within a minute). Match the remedy to the cause: status expired/error or consent_expires near or past needs BankID re-authorisation via the connect_url; a stale last_synced_at while status is active usually means the subscription lapsed or every account is deselected, so point the user to Installningar -> Bank instead of re-authorising. To add another bank, give the user the connect_url.' : (requestedBank ? '' : 'BETTER LINK AVAILABLE: if you know (or can ask) which bank the company uses, call this tool again with bank=; the link then opens that bank\'s consent directly instead of a picker. ') + 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there. They approve with BankID (consent up to 180 days), then CONFIRM WHICH ACCOUNTS to sync in the dialog that opens; the first transactions arrive within a minute of that save. Banks cap PSD2 history (often ~90 days): older history comes via SIE import, not the bank. When the user is back, call this tool again to verify status=active, then continue straight to gnubok_list_uncategorized_transactions without asking.', } }, }, { name: 'gnubok_sync_bank', keywords: ['synka bank', 'banksynk', 'hämta banktransaktioner', 'uppdatera bank', 'synka nu'], title: 'Sync Bank Now', description: 'Sync one PSD2 bank connection now instead of waiting for the nightly run. Use when gnubok_connect_bank shows a stale last_synced_at on an active connection. Server picks the window; synced=false with next_allowed_at means a sync ran or was attempted within 15 min.', inputSchema: { type: 'object', additionalProperties: false, required: ['connection_id'], properties: { connection_id: { type: 'string', description: 'connection_id from gnubok_connect_bank.', }, }, }, outputSchema: { type: 'object', properties: { synced: { type: 'boolean' }, connection_id: { type: 'string' }, bank: { type: ['string', 'null'] }, imported: { type: 'integer' }, duplicates: { type: 'integer' }, last_synced_at: { type: ['string', 'null'] }, next_allowed_at: { type: ['string', 'null'] }, instructions: { type: 'string' }, }, required: ['synced', 'connection_id', 'instructions'], }, annotations: ANNOTATIONS_WRITE_OPEN_WORLD, async execute(args, companyId, userId, supabase) { const connectionId = typeof args.connection_id === 'string' ? args.connection_id.trim() : '' const result = await triggerConnectionSync(supabase, { companyId, userId, connectionId, log, }) if (!result.ok) { // Cooldown is not a failure: the data is fresh. Say so in-band so the // agent reads on instead of retrying; everything else is a real // error and flows through the structured envelope (BANK_SYNC_* codes // carry the remediation, incl. "hand the user the connect link"). if (result.code === 'BANK_SYNC_COOLDOWN') { return { synced: false, connection_id: result.connection_id, bank: null, last_synced_at: null, next_allowed_at: result.next_allowed_at ?? null, instructions: 'A sync ran or was attempted on this connection within the last 15 minutes. Check last_synced_at via gnubok_connect_bank: if it is fresh, transactions and balances are already current, continue with gnubok_list_uncategorized_transactions. If it is still stale, the previous attempt failed; retry once after next_allowed_at, never before.', } } throw Object.assign( new Error(`Bank sync refused for connection ${result.connection_id}: ${result.code}`), { code: result.code }, ) } return { synced: true, connection_id: result.connection_id, bank: result.bank, imported: result.imported, duplicates: result.duplicates, last_synced_at: result.last_synced_at, next_allowed_at: null, instructions: result.imported > 0 ? `${result.imported} new transaction(s) fetched from the bank (${result.from_date} to ${result.to_date}). Continue with gnubok_list_uncategorized_transactions.` : `The bank had nothing new for ${result.from_date} to ${result.to_date}: the data was already complete. Banks report with up to 48 hours of delay, so today's transactions often arrive tomorrow; do not call again for that.`, } }, }, { name: 'gnubok_connect_skatteverket', keywords: ['skatteverket', 'koppla skatteverket', 'deklarationsombud'], title: 'Connect Skatteverket', description: 'Skatteverket connection status plus the browser link where the user authorises Accounted (BankID as firmatecknare; logged in to Accounted there). Enables skattekonto sync and filing of moms/AGI. Use after gnubok_create_company or when a filing tool reports no connection.', inputSchema: { type: 'object', additionalProperties: false, properties: {}, }, outputSchema: { type: 'object', properties: { available: { type: 'boolean' }, connected: { type: 'boolean' }, token_expires_at: { type: ['string', 'null'] }, connect_url: { type: ['string', 'null'] }, instructions: { type: 'string' }, }, required: ['available', 'connected', 'token_expires_at', 'connect_url', 'instructions'], }, _meta: { ui: { resourceUri: 'ui://connect-card/app.html' } }, annotations: ANNOTATIONS_READ_ONLY, async execute(_args, companyId, _userId, supabase) { const enabled = process.env.SKATTEVERKET_ENABLED === 'true' const { data, error } = await supabase .from('skatteverket_tokens') .select('expires_at') .eq('company_id', companyId) .order('expires_at', { ascending: false }) .limit(1) .maybeSingle() if (error) throw error const token = data as { expires_at: string } | null const connected = Boolean(token) const connectUrl = `${connectLinkBaseUrl()}/api/extensions/ext/skatteverket/authorize?return_to=%2F` return { available: enabled, connected, token_expires_at: token?.expires_at ?? null, connect_url: enabled ? connectUrl : null, instructions: !enabled ? 'The Skatteverket integration is not enabled on this installation. Declarations can still be downloaded as files and filed manually at skatteverket.se.' : connected ? 'Skatteverket is connected. Skattekonto syncs automatically; momsdeklaration and AGI can be filed from here (each filing stages for approval).' : 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there; Skatteverket asks them to identify with BankID as firmatecknare and approve the access, then they land back in Accounted. Tell them to come back here when done.', } }, }, { name: 'gnubok_connect_migration', keywords: ['migrering', 'byta system', 'flytta bokföring'], title: 'Connect Previous System', description: 'Connect card into the migration wizard for a NAMED previous system. API systems (fortnox/bjornlunden/briox/wint) fetch all fiscal years plus invoices, customers and documents; visma/bokio complement AFTER a SIE import. Same one-click feel as the bank/Skatteverket cards.', inputSchema: { type: 'object', additionalProperties: false, properties: { provider: { type: 'string', enum: ['fortnox', 'bjornlunden', 'briox', 'wint', 'visma', 'bokio'], description: 'The previous system the user named', }, }, required: ['provider'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { provider: { type: 'string' }, provider_name: { type: 'string' }, api_connected: { type: 'boolean', description: 'true = the wizard fetches SIE directly from the system; false = a SIE file import comes first' }, connect_url: { type: 'string' }, instructions: { type: 'string' }, }, required: ['provider', 'provider_name', 'api_connected', 'connect_url', 'instructions'], }, _meta: { ui: { resourceUri: 'ui://connect-card/app.html' } }, annotations: ANNOTATIONS_READ_ONLY, async execute(args) { const PROVIDERS: Record = { fortnox: { name: 'Fortnox', api: true }, bjornlunden: { name: 'Björn Lundén', api: true }, briox: { name: 'Briox', api: true }, wint: { name: 'Wint', api: true }, visma: { name: 'Visma eEkonomi', api: false }, bokio: { name: 'Bokio', api: false }, } const provider = String(args.provider ?? '') const info = PROVIDERS[provider] if (!info) { throw codedError( 'VALIDATION_ERROR', `Unknown provider "${provider}". Supported: ${Object.keys(PROVIDERS).join(', ')}.` ) } const connectUrl = `${connectLinkBaseUrl()}/import?mode=migration&provider=${provider}` return { provider, provider_name: info.name, api_connected: info.api, connect_url: connectUrl, instructions: info.api ? `On claude.ai/Claude Desktop a connect card with an open-in-browser button renders with this result; elsewhere give the user the connect_url. The wizard connects to ${info.name} (login there), fetches every fiscal year and imports bookkeeping PLUS invoices, customers, suppliers and documents. The user comes back here when the wizard reports done.` : `${info.name} has no API export: run the SIE-file import FIRST (gnubok_create_sie_upload drop card). This wizard link then complements with invoices and customers. On claude.ai/Desktop a connect card renders; elsewhere give the user the connect_url.`, } }, }, { name: 'gnubok_get_company_settings', keywords: ['inställningar', 'företagsinställningar', 'momsperiod'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(error) 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', keywords: ['inställningar', 'företagsinställningar'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 dbError(error) 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: ANNOTATIONS_READ_ONLY, 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. No company at // all (anonymous or not-yet-onboarded caller, issue #1814) means no // context to filter on: the full list, without the two lookups. const [settings, employeeCount] = companyId ? 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), ]) : [{ data: null }, { count: 0 }] 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: ANNOTATIONS_READ_ONLY, 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: ANNOTATIONS_STAGED_WRITE, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 recorded for product-team triage; it is read and has led to fixes. Include ids and expected vs actual so it can be verified.', } }, }, { 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', description: 'Dimension registry snapshot (kostnadsställe/projekt): an enabled flag plus the registered dimensions with their codes, counts and top values. OMITTED when the company has none registered; presence means lines can be tagged via the dims bag on gnubok_create_voucher, and enabled=true means dims-bag values are validated against the registry.', }, ledger_context: { type: 'object', description: 'Digest of how this company books things: top-5 counterparty + top-3 supplier patterns, each with an evidence block (seen_12m, agree, share, last_booked) and the rolling window it was computed over. Evidence is historical frequency, NOT permission to auto-book: weigh seen count AND recency, never a ratio alone. OMITTED when not computable. Field-by-field detail, plus account usage, explicit rules, VAT profile and conventions, is in the Accounted://ledger/context resource.', }, recommended_tools: { type: 'array', items: { type: 'object' }, description: 'Per-workflow tool loadouts, ordered by call sequence: each entry names a workflow, describes it, and lists the exact registry tools it needs. Deferred-loading harnesses batch-load a whole cluster in one call (ToolSearch select:a,b,c). Static; validated against the registry at module load.', }, feedback_channel: { type: 'object', additionalProperties: false, description: 'How to report a missing tool, misleading description or wrong result.', properties: { tool: { type: 'string' }, when: { type: 'string' }, include: { type: 'string' }, }, required: ['tool', 'when', 'include'], }, skatteverket_connection: { type: 'object', description: 'Present only when a Skatteverket connection exists. Carries status ("active" or "needs_reconsent") and the grant detail behind it. needs_reconsent: only a person can fix it (BankID under Inställningar → Skatteverket); warn the user before starting SKV work.', }, }, required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory', 'recommended_tools'], }, annotations: ANNOTATIONS_READ_ONLY, 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 } })() // Skatteverket connection health, so the agent warns the user at // session start instead of discovering a dead session mid-task. // Best-effort and emitted only when a connection (or verified system // grant) exists: never-connected companies pay no payload for it. // The system-before-user priority mirrors resolveReadAuth // (skatteverket/lib/resolve-auth.ts); not reused directly because the // briefing needs token metadata (createdAt, reconsent status) that // resolveReadAuth deliberately collapses into an auth result. const safeSkvConnection = (async (): Promise< | { status: 'active' | 'needs_reconsent' source: 'user' | 'system' connected_at?: string | null message?: string } | null > => { try { if (process.env.SKATTEVERKET_ENABLED !== 'true') return null if ( getSystemAuthMode() === 'on' && isSystemAuthConfigured() && (await hasVerifiedGrant(companyId, 'lasombud')) ) { return { status: 'active', source: 'system' } } const token = await findCompanyTokenUser(supabase, companyId) if (!token) return null if (token.needsReconsent) { return { status: 'needs_reconsent', source: 'user', connected_at: token.createdAt, message: 'Skatteverket-sessionen har gått ut. Skatteverkets personliga inloggning gäller bara ca 1 timme, så detta är normalt. Be användaren ansluta igen med BankID under Inställningar → Skatteverket; bara en person kan göra det, så försök inte med Skatteverket-verktyg förrän användaren bekräftat.', } } return { status: 'active', source: 'user', connected_at: token.createdAt } } 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 const skvConnection = await safeSkvConnection 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 } : {}), ...(skvConnection ? { skatteverket_connection: skvConnection } : {}), // 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], })), // The feedback tool was previously discoverable only by scanning // tools/list; agents that never scan never report. Surface it here, // once, in the call every session starts with. feedback_channel: { tool: 'gnubok_feedback', when: 'A tool is missing, a description misled you, a result looks wrong, or something worked unusually well.', include: 'context (what you tried, ids, expected vs actual), suggestion, and tool_name. Rate-limited 1/min/key: batch a session\'s findings into one call.', }, } }, }, { name: 'gnubok_create_transactions', keywords: ['banktransaktion', 'kontohändelse', 'kontoutdrag'], title: 'Create Bank Transactions', description: 'Stage bank/cash-account transactions; each becomes a pending operation. For external rows (Airtable, CSV); max 10. A transaction models a cash-account movement: for cashless events (privat utlägg) use gnubok_create_voucher.', 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['okategoriserade', 'banktransaktioner', 'kontohändelser', 'att bokföra'], title: 'List Uncategorized Transactions', description: 'List bank transactions with no journal entry yet, newest first. Paginated. cash_account_id narrows to one bank account.', 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)' }, cash_account_id: { type: 'string' }, }, examples: [{}, { limit: 100, offset: 100 }], }, 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', 'null'] }, merchant_name: { type: ['string', 'null'] }, reference: { type: ['string', 'null'] }, is_business: { type: ['boolean', 'null'] }, category: { type: ['string', 'null'] }, cash_account_id: { type: ['string', 'null'] }, cash_account_ledger: { type: ['string', 'null'] }, }, }), annotations: ANNOTATIONS_READ_ONLY, 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 cashAccountId = typeof args.cash_account_id === 'string' && args.cash_account_id.trim() ? args.cash_account_id.trim() : null // The ledger number now sits next to the id in every row, so an agent // may well pass "1930" here: fail with a clear message instead of a // raw Postgres uuid cast error. if (cashAccountId && !UUID_RE.test(cashAccountId)) { throw new Error('cash_account_id must be a cash account UUID (cash_accounts.id), not a ledger account number') } // Get total count let countQuery = supabase .from('transactions') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .is('journal_entry_id', null) if (cashAccountId) countQuery = countQuery.eq('cash_account_id', cashAccountId) const { count: totalCount, error: countError } = await countQuery if (countError) throw dbError(countError) let listQuery = supabase .from('transactions') .select( 'id, date, description, amount, currency, merchant_name, reference, is_business, category, cash_account_id' ) .eq('company_id', companyId) .is('journal_entry_id', null) if (cashAccountId) listQuery = listQuery.eq('cash_account_id', cashAccountId) const { data, error } = await listQuery .order('date', { ascending: false }) .range(offset, offset + limit - 1) if (error) throw dbError(error) // Resolve the bank account's BAS ledger for the rows on this page so a // per-account reconciliation can be driven from outside (customer // report: the API never said which bank account a transaction belongs // to). One lookup per page, only when any row carries a cash_account_id. const pageRows = (data ?? []) as Array<{ id: string; cash_account_id?: string | null }> const cashAccountIds = [...new Set(pageRows.map((t) => t.cash_account_id).filter((v): v is string => !!v))] const ledgerByCashAccount = new Map() if (cashAccountIds.length > 0) { const { data: cashRows, error: cashError } = await supabase .from('cash_accounts') .select('id, ledger_account') .eq('company_id', companyId) .in('id', cashAccountIds) if (cashError) throw dbError(cashError) for (const c of (cashRows ?? []) as Array<{ id: string; ledger_account: string }>) { ledgerByCashAccount.set(c.id, c.ledger_account) } } const rows = pageRows.map((t) => ({ ...t, transaction_id: t.id, cash_account_id: t.cash_account_id ?? null, cash_account_ledger: t.cash_account_id ? ledgerByCashAccount.get(t.cash_account_id) ?? null : null, })) const total = totalCount ?? 0 return { transactions: rows, ...pageTail(rows, total, offset) } }, }, { name: 'gnubok_list_transactions_without_documents', keywords: ['underlag', 'kvitto', 'saknar underlag'], 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', 'null'] }, merchant_name: { type: ['string', 'null'] }, reference: { type: ['string', 'null'] }, is_business: { type: ['boolean', 'null'] }, category: { type: ['string', 'null'] }, journal_entry_id: { type: 'string' }, cash_account_id: { type: ['string', 'null'] }, cash_account_ledger: { type: ['string', 'null'] }, }, }), annotations: ANNOTATIONS_READ_ONLY, 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 dbError(error) 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'}`) } // Every row here is booked by construction (the RPC joins // journal_entries), but transactions.category keeps its column default // 'uncategorized' when a row is booked by a manual voucher plus link // (lib/transactions/link-journal-entry.ts never writes category). // gnubok_list_uncategorized_transactions uses that same word to mean // "no journal entry yet", so echoing it here made agents try to book an // already-booked deposit (feedback seq 288574). null = no category // label; the journal_entry_id is the booking truth. const rows = (result.transactions ?? []).map((row) => { const r = row as Record return r.category === 'uncategorized' ? { ...r, category: null } : r }) const total = result.total_count ?? 0 return { transactions: rows, ...pageTail(rows, total, offset) } }, }, { name: 'gnubok_list_verifikat_without_documents', keywords: ['verifikat', 'verifikation', 'saknar underlag'], 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', 'null'] }, voucher_number: { type: 'number' }, entry_date: { type: 'string' }, description: { type: 'string' }, source_type: { type: 'string' }, gross_amount: { type: 'number' }, }, }), annotations: ANNOTATIONS_READ_ONLY, 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 dbError(error) 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 return { verifikat: rows, ...pageTail(rows, total, offset) } }, }, { name: 'gnubok_categorize_transaction', keywords: ['bokför', 'kontera', 'kategorisera', 'banktransaktion'], title: 'Categorize Bank Transaction', description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross (always the tx\'s cash account). account_override books the business side on any active account. Cashless events (privat utlägg): gnubok_create_voucher.', 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. In the transaction\'s currency, like belopp; booked in SEK at its exchange rate. Requires a rate-based vat_treatment. Swedish moms only: foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' }, account_override: { type: 'string', pattern: '^\\d{4}$', description: 'Books the business side (debit when money goes out, credit when money comes in) on this kontoplan account instead of the category default: the ONLY way to reach company-custom accounts (e.g. VMB). category is still required: it decides direction and VAT; the override only replaces its default account. Must exist and be active (gnubok_list_accounts; create via gnubok_create_account). VMB purchases/sales carry no deductible moms: use vat_treatment "exempt". Without an explicit vat_treatment (or vat_amount) the override books GROSS with no auto-VAT line: a moms leg is never guessed onto a custom account. Class-2 overrides outside 2610-2649 always drop auto-VAT. Not valid with category "private". State the actual affärshändelse in notes (BFL 5 kap).' }, 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'], // The two combinations the prose above describes and callers still get // wrong: an account_override without an explicit vat_treatment (books // GROSS, no moms line), and representation without deltagare + syfte. examples: [ { transaction_id: '3f1a...', category: 'expense_office' }, { transaction_id: '3f1a...', category: 'expense_other', account_override: '4600', vat_treatment: 'standard_25' }, { transaction_id: '3f1a...', category: 'expense_representation', notes: 'Anna Andersson (Acme AB), kundmöte om ramavtal' }, ], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, 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') // Runtime guard (hosts don't always enforce inputSchema patterns). const accountOverride = args.account_override === undefined ? undefined : String(args.account_override).trim() if (accountOverride !== undefined && !ACCOUNT_NUMBER_RE.test(accountOverride)) { throw new Error('account_override must be exactly 4 digits, e.g. "4020".') } // Presence guard (hosts don't always enforce inputSchema `required`). // Without it a call carrying only account_override reached the enum // check and surfaced as 'Invalid category "undefined"'. The enum check // in categorizeTransactionCore still handles unknown category strings. const category = typeof args.category === 'string' ? args.category.trim() : '' if (!category) { throw new Error( 'category is required; account_override only overrides the category\'s default account. ' + `Valid categories: ${VALID_CATEGORIES.join(', ')}`, ) } // Compute the preview (accounts, amounts, VAT lines) const result = await categorizeTransactionCore( args.transaction_id as string, category as TransactionCategory, args.vat_treatment as VatTreatment | undefined, vatAmount, accountOverride, userId, companyId, supabase, false // preview mode: execution happens at approval time via gnubok_approve_pending_operation ) // Already booked: categorizeTransactionCore returns a success-shaped // object here, which fails STAGED_OPERATION_SCHEMA on strict clients and // reached the agent as "Structured content does not match the tool's // output schema" with the real reason swallowed (feedback seq 288574). // Throw instead: the dispatcher's isError path carries no // structuredContent, so the message survives. if (result.success && result.journal_entry_created === false) { throw new Error( `Transaction is already booked (journal_entry_id ${result.journal_entry_id ?? 'unknown'}); ` + 'nothing to categorize. Use gnubok_list_uncategorized_transactions to find unbooked ones, ' + 'or gnubok_correct_entry / gnubok_reverse_journal_entry to change the existing verifikat.', ) } // 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, vat_treatment: args.vat_treatment || null, vat_amount: vatAmount ?? null, account_override: accountOverride ?? 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, ...(accountOverride ? { account_override: accountOverride } : {}), amount: result.amount, currency: result.currency, // Entry date, so the review queue can show which fiscal year a // categorization belongs to (two open years are indistinguishable // from the title alone). date: tx?.date ?? null, // 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', keywords: ['kvitto', 'underlag', 'matcha kvitton'], 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: ANNOTATIONS_READ_ONLY, _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 dbError(error) return { transactions: data ?? [], categories: [...VALID_CATEGORIES], vat_treatments: [...VALID_VAT_TREATMENTS], } }, }, // ── Customer tools ─────────────────────────────────────────── { name: 'gnubok_list_customers', keywords: ['kund', 'kunder', 'kundregister'], title: 'List Customers', description: 'List active customers. Use to look up customer_id for invoice creation. include_archived=true adds archived rows.', inputSchema: { type: 'object', additionalProperties: false, properties: { include_archived: { type: 'boolean' } }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { customers: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['customers', 'count'], }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { // Archived rows (v1 API soft-delete) are hidden by default: same // `archived_at IS NULL` convention and opt-in flag as the v1 list. const includeArchived = args.include_archived === true // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at // 1000 rows. Page on the unique id, then re-sort by name for display. type ListedCustomer = { id: string name: string customer_type: string org_number: string | null personal_number: string | null } let rows: ListedCustomer[] try { rows = await fetchAllRows(({ from, to }) => { const query = supabase .from('customers') .select('id, name, customer_type, email, org_number, vat_number, personal_number, default_payment_terms, city, country, archived_at') .eq('company_id', companyId) return (includeArchived ? query : query.is('archived_at', null)) .order('id', { ascending: true }) .range(from, to) }) } catch (error) { throw dbError(error) } rows.sort((a, b) => a.name.localeCompare(b.name, 'sv') || a.id.localeCompare(b.id)) // GDPR art. 5.1 c, same rule as the v1 list: an individual's // personnummer never leaves this tool raw. personal_number is stored as // ciphertext and is exposed only as personal_number_masked // (********-1234); a legacy individual row that still carries the // personnummer in org_number (written before the write paths started // moving it into personal_number) shows it masked the same way, and its // org_number is nulled rather than listed. const customers = rows.map(({ personal_number, ...customer }) => { if (customer.customer_type !== 'individual') return customer const legacyInOrgNumber = orgNumberHoldsPersonalNumber(customer.customer_type, customer.org_number) return { ...customer, org_number: legacyInOrgNumber ? null : customer.org_number, personal_number_masked: maskStoredCustomerPersonalNumber(personal_number) ?? (legacyInOrgNumber ? maskCustomerPersonalNumber(customer.org_number) : null), } }) return { customers, count: customers.length } }, }, { name: 'gnubok_create_customer', keywords: ['kund', 'ny kund'], 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', }, customer_number: { type: 'string', maxLength: 32 }, email: { type: 'string', description: 'Email address' }, org_number: { type: 'string', description: 'Swedish org number (business types). A personnummer belongs in personal_number.' }, personal_number: { type: 'string', description: 'Personnummer for customer_type=individual. Encrypted at staging, masked on read.' }, vat_number: { type: 'string', description: 'EU VAT number' }, payment_terms: { type: 'number', description: 'Days. Default: the company setting, else 30.' }, address: { type: 'string', description: 'Street address' }, postal_code: { type: 'string' }, city: { type: 'string' }, country: { type: 'string', description: 'ISO 3166-1 alpha-2 (default SE; a name is normalised). Must agree with customer_type and the VAT prefix.' }, 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') } // Runtime guard (hosts don't always enforce inputSchema maxLength). const customerNumberArg = args.customer_number if (customerNumberArg != null && typeof customerNumberArg !== 'string') { throw new Error('customer_number must be a string.') } const customerNumber = typeof customerNumberArg === 'string' ? customerNumberArg.trim() : '' if (customerNumber.length > 32) { throw new Error('customer_number must be at most 32 characters.') } // Identifiers. A personnummer belongs in personal_number on an // individual and nowhere else. The business-type guard mirrors // CreateCustomerSchema (nothing masks org_number, GDPR art. 5.1 c); a // personnummer-shaped org_number on an individual is the personnummer // submitted in the wrong field, which is all an agent COULD do before // this tool had a personal_number input, so it is moved rather than // refused. Everything is checked here, at staging, so the user never // approves an operation that then fails at commit. const orgNumberArg = typeof args.org_number === 'string' ? args.org_number.trim() : '' const personalNumberArg = typeof args.personal_number === 'string' ? args.personal_number.trim() : '' if (orgNumberArg && customerType !== 'individual' && looksLikeSwedishPersonalNumber(orgNumberArg)) { throw new Error( 'org_number looks like a Swedish personal identity number (personnummer). Create the customer with ' + 'customer_type "individual" and pass the number as personal_number instead, so it is stored encrypted ' + 'and masked in lists.', ) } if (personalNumberArg && customerType !== 'individual') { throw new Error('personal_number is only allowed for customer_type "individual".') } let orgNumber: string | null = orgNumberArg || null let personalNumber: string | null = personalNumberArg || null if (orgNumberHoldsPersonalNumber(customerType, orgNumber)) { const rerouted = normalizeReroutedPersonalNumber(orgNumber!) if (personalNumber && personalNumberDigits(personalNumber) !== personalNumberDigits(rerouted)) { throw new Error( 'org_number looks like a personnummer and differs from personal_number. An individual customer keeps ' + 'its personnummer in personal_number; leave org_number empty.', ) } personalNumber = personalNumber ?? rerouted orgNumber = null } if (personalNumber && !PERSONAL_NUMBER_PLAINTEXT_RE.test(personalNumber)) { throw new Error('personal_number must be a Swedish personnummer: YYYYMMDD-XXXX, YYMMDD-XXXX or the digits alone.') } // Country: stored as ISO 3166-1 alpha-2 (a name is accepted and // normalised), and it must agree with the customer type and the VAT // prefix. An EU customer saved with country SE used to get reverse // charge with nothing objecting until the periodisk sammanställning // (#2025). Checked at staging so the user never approves an operation // that then fails at commit. const countryArg = typeof args.country === 'string' ? args.country.trim() : '' const vatNumberArg = typeof args.vat_number === 'string' ? args.vat_number : null const country = countryArg ? normalizeCountryCode(countryArg) : defaultCountryForParty(customerType, vatNumberArg) if (!country) { throw new Error( countryArg ? `country "${countryArg}" is not an ISO 3166-1 alpha-2 code or a known country name. Use a code such as SE, DE or NO.` : customerType === 'eu_business' ? 'country is required for an EU business unless vat_number carries an EU country prefix (e.g. DE811234567).' : 'country is required for a non-EU business.', ) } const countryIssue = checkCountryConsistency({ partyType: customerType, country, vatNumber: vatNumberArg, }) if (countryIssue) { throw new Error(`country: ${COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en}`) } // Resolved at staging, not at commit, so the approval preview shows the // terms the row will actually get: the caller's value, else the // company's invoice_default_days, else 30. (Staging `|| 30` here is why // #1708's fix never reached the MCP path.) const paymentTermsArg = Number(args.payment_terms) const paymentTerms = await resolveDefaultPaymentTerms( supabase, companyId, Number.isFinite(paymentTermsArg) && paymentTermsArg > 0 ? paymentTermsArg : undefined, ) const preview: Record = { name: name.trim(), customer_type: customerType, customer_number: customerNumber || null, email: (args.email as string) || null, org_number: orgNumber, vat_number: (args.vat_number as string) || null, payment_terms: paymentTerms, address: (args.address as string) || null, postal_code: (args.postal_code as string) || null, city: (args.city as string) || null, country, // The preview (and the approval UI that renders it) only ever sees // the masked form. personal_number_masked: personalNumber ? maskCustomerPersonalNumber(personalNumber) : null, } // PII rule (staging-pii-guard): pending_operations.params never holds // a plaintext personnummer. Encrypted here, same as create_employee; // the executor stores the ciphertext as-is. const { personal_number_masked: _masked, ...paramsBase } = preview const params: Record = { ...paramsBase, personal_number_encrypted: personalNumber ? encryptCustomerPersonalNumber(personalNumber) : null, } return stagePendingOperation(supabase, companyId, userId, 'create_customer', `Ny kund: ${preview.name as string}`, params, preview, 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, // The ciphertext has a random IV, so params differ on every call; // the masked preview is the stable identity of the request. idempotencyParams: preview, } ) }, }, { name: 'gnubok_update_customer', keywords: ['kund', 'ändra kund'], 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', description: 'ISO 3166-1 alpha-2 (a name is normalised). Must agree with customer_type and the VAT prefix.' }, org_number: { type: 'string' }, personal_number: { type: ['string', 'null'], description: 'Personnummer, individual customers only. Encrypted at staging, masked on read. Null clears; a masked value (********-1234) means unchanged.', }, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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] } // personal_number never enters `changes` in plaintext: staging-pii-guard // forbids that key in staged payloads. REST PATCH semantics // (app/api/customers/[id]/route.ts): a masked echo of a read // ('********-1234' or '********-????') carries no new value and is // dropped; explicit null clears; a plaintext personnummer is validated // here and staged as AES-256-GCM ciphertext, so the approval preview // only ever sees the masked form. const personalNumberArg = args.personal_number let personalNumber: string | null = null if (personalNumberArg !== undefined) { if (personalNumberArg !== null && typeof personalNumberArg !== 'string') { throw new Error('personal_number must be a string or null.') } const trimmed = personalNumberArg === null ? null : personalNumberArg.trim() if (trimmed === null) { changes.personal_number_encrypted = null } else if (isMaskedPersonalNumber(trimmed)) { // Echo of a read: leave the stored personnummer alone. } else if (!PERSONAL_NUMBER_PLAINTEXT_RE.test(trimmed)) { throw new Error( 'personal_number must be a Swedish personnummer: YYYYMMDD-XXXX, YYMMDD-XXXX or the digits alone. ' + 'Null clears the stored value; a masked value leaves it unchanged.', ) } else { personalNumber = trimmed changes.personal_number_encrypted = encryptCustomerPersonalNumber(trimmed) } } 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, personal_number') .eq('id', parsed.data.customer_id) .eq('company_id', companyId) .maybeSingle() if (error) throw dbError(error) if (!current) throw new Error('Customer not found.') // Same guard as gnubok_create_customer and the REST PATCH route: only // individual rows get their identifiers masked on read (GDPR art. // 5.1 c), so a personnummer on a business customer is refused. Checked // against the type the row will END UP with, so a simultaneous type // change cannot smuggle one through. const effectiveCustomerType = (parsed.data.changes.customer_type ?? current.customer_type) as string if (personalNumber && effectiveCustomerType !== 'individual') { throw new Error('personal_number is only allowed for customer_type "individual".') } // Country vs type vs VAT prefix, judged on the row as it will END UP: // a type change alone can make a stored country wrong, and a country // change alone can contradict the stored VAT number (#2025). Judged // only when one of the three is in the update: a legacy row that is // already contradictory must still be able to change its email. const { customer_type: newType, country: newCountry, vat_number: newVat } = parsed.data.changes const countryIssue = newType !== undefined || newCountry !== undefined || newVat !== undefined ? checkCountryConsistency({ partyType: effectiveCustomerType, country: newCountry ?? current.country, vatNumber: newVat ?? current.vat_number, }) : null if (countryIssue) { throw new Error(`country: ${COUNTRY_CONSISTENCY_MESSAGES[countryIssue].en}`) } 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, // The stored value is ciphertext; the preview (and the approval UI // that renders it) only ever sees the masked form. personal_number_masked: maskStoredCustomerPersonalNumber(current.personal_number), } // The preview never carries the ciphertext either: a personal_number // change is shown as its masked form (or null when clearing). const { personal_number_encrypted: _stagedCiphertext, ...visibleChanges } = parsed.data.changes const previewChanges: Record = { ...visibleChanges } if (parsed.data.changes.personal_number_encrypted !== undefined) { previewChanges.personal_number_masked = personalNumber ? maskCustomerPersonalNumber(personalNumber) : null } return stagePendingOperation( supabase, companyId, userId, 'update_customer', `Uppdatera kund: ${current.name}`, 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, // The ciphertext in params has a random IV, so params differ on // every retry when a personnummer is staged; hash the masked // preview instead (same reasoning as gnubok_create_customer). Only // set for personnummer-bearing updates so every other update keeps // its previous hash identity. ...(parsed.data.changes.personal_number_encrypted !== undefined ? { idempotencyParams: { customer_id: parsed.data.customer_id, changes: previewChanges } } : {}), }, ) }, }, // ── Article tools (artikelregister) ────────────────────────── { name: 'gnubok_list_articles', keywords: ['artikel', 'artiklar', 'produkter', 'artikelregister'], 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: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { // 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() // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at // 1000 rows. Page on the unique id, then re-sort by name for display. let articles: { id: string; name: string }[] try { articles = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => { 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) if (safe) { q = q.or(`name.ilike.%${safe}%,article_number.ilike.%${safe}%`) } return q.order('id', { ascending: true }).range(from, to) }) } catch (error) { throw dbError(error) } articles.sort((a, b) => a.name.localeCompare(b.name, 'sv') || a.id.localeCompare(b.id)) return { articles, count: articles.length } }, }, { name: 'gnubok_create_article', keywords: ['artikel', 'produkt', 'ny artikel'], 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 flag for service articles: a Skatteverket arbetstypskod (ROT: BYGG, EL, GLAS_PLAT, MARK_DRAN, MURNING, MALNING, VVS; RUT: STAD, KLAD, SNOSKOTTNING, TRADGARD, BARNPASS, PERSONLIG_OMS, FLYTT, IT, REPARATION, MOBLERING, TILLSYN, TRANSPORT, TVATT) or the bare kind ROT / RUT. Picking the article on an invoice line pre-fills the skattereduktion (and the arbetstyp when a code is given). Any other value is rejected.' }, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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', keywords: ['artikel', 'produkt'], 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', description: 'Skatteverket arbetstypskod (e.g. BYGG, STAD) or bare ROT / RUT; empty string clears. See gnubok_create_article.' }, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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', keywords: ['faktura', 'kundfaktura', 'fakturor', 'obetalda', 'förfallna', 'påminnelse', 'offert', 'offerter'], title: 'List Customer Invoices', description: 'List invoices and quotes (offerter) for the active company, newest first. Optional status, document_type and quote_status filters. Heed invoice_register_coverage/coverage_note: after a migration or backfill, older invoices may exist only as journal entries and NOT appear here.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited'], description: 'Filter by invoice status', }, document_type: { type: 'string', enum: ['invoice', 'proforma', 'delivery_note', 'quote'], }, quote_status: { type: 'string', enum: ['open', 'accepted', 'declined', 'expired'], description: 'Quotes only', }, 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: ANNOTATIONS_READ_ONLY, 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 const documentType = args.document_type as string | undefined const quoteStatus = args.quote_status as string | undefined let query = supabase .from('invoices') .select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, valid_until, quote_status, default_dimensions, customers(name)', { count: 'exact' }) .eq('company_id', companyId) if (status) { query = query.eq('status', status) } if (documentType) { query = query.eq('document_type', documentType) } if (quoteStatus) { // quote_status only exists on quotes: combining it with another // document_type can never match, so say so instead of returning []. if (documentType && documentType !== 'quote') { throw registryError('VALIDATION_ERROR') } // "expired" is derived (lib/invoices/quote-status): an open quote whose // valid_until has passed. Never stored, so it is a predicate here. query = query.eq('document_type', 'quote') if (quoteStatus === 'expired') { query = query.eq('quote_status', 'open').lt('valid_until', new Date().toISOString().split('T')[0]) } else { query = query.eq('quote_status', quoteStatus) } } const { data, error, count } = await query .order('invoice_date', { ascending: false }) .order('id', { ascending: false }) .range(offset, offset + limit) if (error) throw dbError(error) 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, valid_until: inv.valid_until ?? null, // Effective status: open/accepted/declined/expired for quotes, null otherwise. quote_status: effectiveQuoteStatus(inv as { quote_status?: string | null; valid_until?: string | null }), 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) // Register-coverage disclosure: the register only holds invoices // created in Accounted. Migrated/backfilled invoice history lives as // journal entries, so without this field an agent reads a silently // incomplete list as complete. First page only: continuation pages of // the same listing don't need the disclosure re-queried. Non-fatal: // lookup failure degrades to "no note", never a failed list. let coverage = NO_INVOICE_REGISTER_COVERAGE if (offset === 0) { try { coverage = await fetchInvoiceRegisterCoverage(supabase, companyId) } catch { // keep NO_INVOICE_REGISTER_COVERAGE } } return { invoices, count: invoices.length, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + invoices.length } : {}), // Omitted (not nulled) on continuation pages: a null covers_from on // page 2 would read as "no coverage limit" when it just wasn't queried. ...(offset === 0 ? { invoice_register_coverage: coverage } : {}), ...(coverage.has_pre_register_invoices && coverage.covers_from ? { coverage_note: `Äldsta fakturan i fakturaregistret är daterad ${coverage.covers_from}. Det finns bokförda verifikat med kundfordringar (1510/1513) före det datumet: äldre kundfakturor kan ligga som verifikat utanför registret (t.ex. efter en migrering) och syns inte i detta svar. Sök i journalen (gnubok_query_journal) för perioden före ${coverage.covers_from}.`, } : {}), } }, }, { name: 'gnubok_get_invoice', keywords: ['faktura', 'kundfaktura'], title: 'Get Invoice', description: 'One invoice: header plus every line with article_id, revenue_account, vat_rate and dimensions. Read it before gnubok_update_invoice (items are a FULL REPLACE) so each line goes back with its article linkage. editable_draft tells whether an edit is possible.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID from gnubok_list_invoices' }, }, required: ['invoice_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string' }, invoice_number: { type: ['string', 'null'], description: 'null until sent' }, status: { type: 'string' }, document_type: { type: 'string' }, valid_until: { type: ['string', 'null'] }, quote_status: { type: ['string', 'null'], description: 'Quotes only; expired is derived' }, customer_id: { type: 'string' }, customer_name: { type: ['string', 'null'] }, invoice_date: { type: 'string' }, due_date: { type: ['string', 'null'] }, delivery_date: { type: ['string', 'null'] }, currency: { type: 'string' }, subtotal: { type: 'number' }, vat_amount: { type: 'number' }, total: { type: 'number' }, paid_amount: { type: 'number' }, remaining_amount: { type: ['number', 'null'] }, your_reference: { type: ['string', 'null'] }, our_reference: { type: ['string', 'null'] }, invoice_marking: { type: ['string', 'null'], description: 'Fakturamärkning (buyer marking), separate from your_reference' }, notes: { type: ['string', 'null'] }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' } }, editable_draft: { type: 'boolean', description: 'true when gnubok_update_invoice can edit it' }, items: { type: 'array', description: 'Lines in display order; pass them back to gnubok_update_invoice verbatim: article, ROT/RUT, accrual and account fields survive only if passed back.', items: { type: 'object', additionalProperties: false, properties: { invoice_item_id: { type: 'string' }, line_type: { type: 'string', description: 'product or text' }, description: { type: 'string' }, quantity: { type: 'number' }, unit: { type: 'string' }, unit_price: { type: 'number' }, discount_percent: { type: 'number', description: 'Line discount 0-100; line_total is net of it' }, line_total: { type: 'number' }, vat_rate: { type: 'number' }, vat_amount: { type: 'number' }, article_id: { type: ['string', 'null'] }, revenue_account: { type: ['string', 'null'], description: 'Posting-account override; null books by VAT treatment' }, deduction_type: { type: ['string', 'null'], description: 'rot, rut or null' }, labor_hours: { type: ['number', 'null'] }, work_type: { type: ['string', 'null'] }, housing_designation: { type: ['string', 'null'], description: 'Fastighetsbeteckning (ROT); property id, not personal data' }, apartment_number: { type: ['string', 'null'] }, brf_org_number: { type: ['string', 'null'] }, accrual_period_start: { type: ['string', 'null'] }, accrual_period_end: { type: ['string', 'null'] }, accrual_balance_account: { type: ['string', 'null'] }, dimensions: { type: 'object', additionalProperties: { type: 'string' } }, }, required: ['invoice_item_id', 'line_type', 'description', 'quantity', 'unit', 'unit_price', 'line_total', 'vat_rate'], }, }, item_count: { type: 'number' }, }, required: ['invoice_id', 'status', 'currency', 'editable_draft', 'items', 'item_count'], }, annotations: ANNOTATIONS_READ_ONLY, // Round-trip read surface for gnubok_update_invoice (issue #1642). Kept // out of the default tools/list: payload-size.bench.test.ts sits at its // ceiling, and the update tool that needs it is search-only as well. catalogVisibility: 'search', async execute(args, companyId, userId, supabase) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required. Use gnubok_list_invoices to find IDs.') // Explicit column list on purpose: invoices carries the encrypted // ROT/RUT personnummer columns, which must never reach the MCP surface. const { data: invoice, error } = await supabase .from('invoices') .select( 'id, invoice_number, status, document_type, valid_until, quote_status, customer_id, invoice_date, due_date, delivery_date, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, your_reference, our_reference, invoice_marking, notes, default_dimensions, journal_entry_id, is_self_billed, credited_invoice_id, customer:customers(name), items:invoice_items(id, sort_order, line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, vat_amount, article_id, revenue_account, sales_order_item_id, deduction_type, labor_hours, work_type, housing_designation, apartment_number, brf_org_number, accrual_period_start, accrual_period_end, accrual_balance_account, dimensions)', ) .eq('id', invoiceId) .eq('company_id', companyId) .maybeSingle() if (error) throw dbError(error) if (!invoice) throw new Error('Invoice not found. Use gnubok_list_invoices to find valid IDs.') type InvoiceLineRow = { id: string sort_order: number | null line_type: string | null description: string quantity: number unit: string unit_price: number discount_percent: number | null line_total: number vat_rate: number vat_amount: number | null article_id: string | null revenue_account: string | null deduction_type: string | null labor_hours: number | null work_type: string | null housing_designation: string | null apartment_number: string | null brf_org_number: string | null accrual_period_start: string | null accrual_period_end: string | null accrual_balance_account: string | null dimensions: Record | null } // PostgREST returns embedded rows unordered: sort here, never rely on // insertion order. const rows = ((invoice.items ?? []) as InvoiceLineRow[]) .slice() .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) const items = rows.map((row) => ({ invoice_item_id: row.id, line_type: row.line_type ?? 'product', description: row.description, quantity: row.quantity, unit: row.unit, unit_price: row.unit_price, discount_percent: row.discount_percent ?? 0, line_total: row.line_total, vat_rate: row.vat_rate, vat_amount: row.vat_amount ?? 0, article_id: row.article_id ?? null, revenue_account: row.revenue_account ?? null, deduction_type: row.deduction_type ?? null, labor_hours: row.labor_hours ?? null, work_type: row.work_type ?? null, // Property identifiers for the ROT claim, needed for the update round // trip; the encrypted personnummer stays out of this surface. housing_designation: row.housing_designation ?? null, apartment_number: row.apartment_number ?? null, brf_org_number: row.brf_org_number ?? null, accrual_period_start: row.accrual_period_start ?? null, accrual_period_end: row.accrual_period_end ?? null, accrual_balance_account: row.accrual_balance_account ?? null, dimensions: row.dimensions ?? {}, })) return { invoice_id: invoice.id, invoice_number: invoice.invoice_number ?? null, status: invoice.status, document_type: invoice.document_type ?? 'invoice', valid_until: invoice.valid_until ?? null, quote_status: effectiveQuoteStatus(invoice), customer_id: invoice.customer_id, customer_name: (invoice.customer as { name?: string } | null)?.name ?? null, invoice_date: invoice.invoice_date, due_date: invoice.due_date ?? null, delivery_date: invoice.delivery_date ?? null, currency: invoice.currency, subtotal: invoice.subtotal, vat_amount: invoice.vat_amount, total: invoice.total, paid_amount: invoice.paid_amount ?? 0, remaining_amount: invoice.remaining_amount ?? null, your_reference: invoice.your_reference ?? null, our_reference: invoice.our_reference ?? null, invoice_marking: invoice.invoice_marking ?? null, notes: invoice.notes ?? null, default_dimensions: (invoice.default_dimensions as Record | null) ?? {}, editable_draft: isEditableInvoiceDraft(invoice), items, item_count: items.length, } }, }, { name: 'gnubok_create_invoice', keywords: ['faktura', 'kundfaktura', 'fakturera', 'ny faktura'], title: 'Create Customer Invoice', description: 'Stage a new invoice or quote (offert). Validates inputs, calculates VAT preview. Items accept dims bags. Approval creates a draft (F-number assigned on send) or an open quote numbered OF-nnn at once; quotes require valid_until and never book.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { customer_id: { type: 'string', description: 'Customer UUID' }, document_type: { type: 'string', enum: ['invoice', 'quote'], description: 'quote = offert (own OF-series, needs valid_until). Default invoice.', }, valid_until: { type: 'string', description: 'YYYY-MM-DD, quotes only' }, 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' }, discount_percent: { type: 'number', description: 'Line discount 0-100 (rabatt); line total and VAT computed net of it' }, vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' }, article_id: { type: 'string', description: 'Optional article UUID from gnubok_list_articles. Prefills description, unit, unit_price, revenue account and, only when compatible with the customer VAT rules, vat_rate. Values set on the line win.', }, line_type: { type: 'string', enum: ['product', 'text'], description: 'text = free-text row: no amounts, never books.' }, revenue_account: { type: ['string', 'null'], description: 'BAS class 1-3 posting-account 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: ['quantity'], }, 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' }, invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference; feeds Peppol BuyerReference.' }, 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: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const customerId = args.customer_id as string const rawItems = args.items as StagedInvoiceLineInput[] if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.') if (!rawItems?.length) throw new Error('At least one item is required.') 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 // Offert: own OF-series allocated at approval, never books, and the // expiry date is the one header field the type adds (CreateInvoiceSchema // parity: valid_until is required exactly when document_type is quote). const documentType = (args.document_type as string | undefined) ?? 'invoice' if (documentType !== 'invoice' && documentType !== 'quote') { throw codedError('VALIDATION_ERROR', 'document_type must be "invoice" or "quote".') } const isQuote = documentType === 'quote' const validUntil = args.valid_until as string | undefined if (isQuote) { if ( typeof validUntil !== 'string' || !ISO_DATE_RE.test(validUntil) || Number.isNaN(new Date(`${validUntil}T00:00:00Z`).getTime()) ) { throw codedError('VALIDATION_ERROR', 'valid_until (YYYY-MM-DD) is required for a quote (offert).') } } else if (validUntil !== undefined) { throw codedError('VALIDATION_ERROR', 'valid_until applies to quotes only: set document_type "quote".') } // Fetch customer (full row for VAT rules) BEFORE the article prefill: // an article's stored rate may only be adopted against this customer's // default rate set (see resolveInvoiceLineFromArticle). 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, customer.country) // The DEFAULT set governs article-rate adoption (web parity: the picker // only adopts a rate the customer could have picked themselves); a // customer locked to a single rate (foreign business 0%) adopts nothing. // Gating below stays on the PERMITTED set: adoption and validation are // deliberately different sets. const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated, customer.country) // Article prefill (web line picker parity): the line's own values win, // the referenced article fills whatever the agent left out. const articleIds = Array.from(new Set(rawItems.map((i) => i.article_id).filter((a): a is string => !!a))) const articlesById = new Map() if (articleIds.length > 0) { const { data: articleRows, error: articleError } = await supabase .from('articles') .select('id, name, unit, price_excl_vat, vat_rate, revenue_account, currency, active') .eq('company_id', companyId) .in('id', articleIds) if (articleError) throw new Error(`Failed to load articles: ${articleError.message}`) for (const row of articleRows ?? []) articlesById.set(row.id, row) } const items = rawItems.map((item, i) => resolveInvoiceLineFromArticle( item, item.article_id ? articlesById.get(item.article_id) : undefined, currency, adoptableVatRates, i, ), ) // 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).') } } // 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, customer.country) const allowedRates = new Set(permittedRates.map((r) => r.rate)) // Calculate per-item VAT (line totals net of any per-line discount) const subtotal = items.reduce( (s, item) => s + computeLineNet(item.quantity, item.unit_price, item.discount_percent), 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 = computeLineNet(item.quantity, item.unit_price, item.discount_percent) vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 } const total = subtotal + vatAmount // Due date from payment terms if not provided. A quote has no payment // due date: due_date mirrors valid_until (build-invoice-write parity). let dueDate = args.due_date as string | undefined if (isQuote) { dueDate = validUntil } else 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', `${isQuote ? 'Ny offert' : 'Ny faktura'}: ${customer.name} ${roundOre(total)} ${currency}`, { customer_id: customerId, document_type: documentType, ...(isQuote ? { valid_until: validUntil } : {}), 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, invoice_marking: (args.invoice_marking 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: computeLineNet(item.quantity, item.unit_price, item.discount_percent), 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, document_type: documentType, ...(isQuote ? { valid_until: validUntil, // The OF-number is allocated by generate_quote_number at // approval; the preview must not show an F-series marker. invoice_number: 'Offert OF-preview', will: 'allocate OF-series number at approval; never books', } : {}), // 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, isQuote ? { description: 'Once approved, the quote exists as an open offert with its OF-number. Record the customer decision with gnubok_set_quote_status; gnubok_convert_invoice creates the faktura from it.', tool: 'gnubok_convert_invoice', } : { 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', } ) }, }, // ── Kundorder (sales orders) ───────────────────────────────── // // The non-ledger document between agreement and invoice. Orders never // book: delivery is a fact the user records, invoicing creates a DRAFT // kundfaktura through the same builder gnubok_create_invoice uses. Every // write stages for approval and commits through lib/sales-orders. { name: 'gnubok_list_sales_orders', keywords: ['kundorder', 'order', 'ordrar', 'orderbekräftelse', 'leverans', 'delfaktura'], title: 'List Sales Orders', description: 'List kundorder (sales orders) for the active company, newest first, with delivery and invoicing progress per order. Optional status and customer filters. Use gnubok_get_sales_order for the lines.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: SALES_ORDER_STATUSES, description: 'Filter by order status' }, customer_id: { type: 'string', description: 'Filter by customer UUID' }, 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)' }, }, }, // Item keys listed in prose instead of declared: the typed summary schema // costs ~350 tokens of the tools/list budget (payload-size.bench.test.ts) // and gnubok_get_sales_order (search-only) declares the same fields fully. outputSchema: paginatedSchema('sales_orders', { type: 'object', description: 'sales_order_id, order_number, status, customer_id, customer_name, order_date, requested_delivery_date, last_delivery_date, currency, subtotal, vat_amount, total, delivery_progress, invoicing_progress (none|partial|full), line_count', }), annotations: ANNOTATIONS_READ_ONLY, 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 = typeof args.status === 'string' ? args.status : undefined const customerId = typeof args.customer_id === 'string' ? args.customer_id.trim() : '' if (status && !(SALES_ORDER_STATUSES as readonly string[]).includes(status)) { throw new Error(`Invalid status "${status}". Allowed: ${SALES_ORDER_STATUSES.join(', ')}`) } let query = supabase .from('sales_orders') .select( 'id, order_number, status, customer_id, order_date, requested_delivery_date, last_delivery_date, currency, subtotal, vat_amount, total, customer:customers(name), items:sales_order_items!sales_order_items_sales_order_id_fkey(id, line_type, quantity, delivered_qty, sort_order)', { count: 'exact' }, ) .eq('company_id', companyId) if (status) query = query.eq('status', status) if (customerId) query = query.eq('customer_id', customerId) const { data, error, count } = await query .order('order_date', { ascending: false }) .order('id', { ascending: false }) .range(offset, offset + limit) if (error) throw dbError(error) const rows = ((data ?? []) as unknown as SalesOrder[]).slice(0, limit) // Progress needs the invoiced quantity per line, which lives on the // linked invoice_items (one RPC for the whole page, RLS applies). const invoiced = await fetchInvoicedQuantities(supabase, rows.map((r) => r.id)) if (!invoiced.ok) throw dbError(invoiced.dbError) const salesOrders = rows.map((row) => salesOrderSummary(decorateSalesOrder(row, invoiced.byItem))) const fetched = (data ?? []).length const hasMore = count == null ? fetched > limit : offset + salesOrders.length < count const total = count ?? offset + salesOrders.length + (hasMore ? 1 : 0) return { sales_orders: salesOrders, count: salesOrders.length, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + salesOrders.length } : {}), } }, }, { name: 'gnubok_get_sales_order', keywords: ['kundorder', 'order', 'orderrader', 'leverans', 'delfaktura'], title: 'Get Sales Order', description: 'One kundorder (sales order): header plus every line with delivered_qty, invoiced_qty and remaining_qty, and the invoices created from it. Read it before registering delivery or invoicing so line ids and remaining quantities are exact.', inputSchema: { type: 'object', additionalProperties: false, properties: { sales_order_id: { type: 'string', description: 'UUID from gnubok_list_sales_orders' }, }, required: ['sales_order_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { ...SALES_ORDER_SUMMARY_PROPS, source_invoice_id: { type: ['string', 'null'], description: 'Proforma the order was converted from, if any' }, your_reference: { type: ['string', 'null'] }, our_reference: { type: ['string', 'null'] }, notes: { type: ['string', 'null'] }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' } }, confirmed_at: { type: ['string', 'null'] }, completed_at: { type: ['string', 'null'] }, cancelled_at: { type: ['string', 'null'] }, items: { type: 'array', description: 'Lines in display order', items: SALES_ORDER_LINE_OUTPUT_SCHEMA }, item_count: { type: 'number' }, invoices: { type: 'array', description: 'Invoices created from this order (all statuses)', items: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string' }, invoice_number: { type: ['string', 'null'], description: 'null until sent' }, status: { type: 'string' }, invoice_date: { type: 'string' }, total: { type: 'number' }, currency: { type: 'string' }, }, required: ['invoice_id', 'status', 'total', 'currency'], }, }, }, required: [...SALES_ORDER_SUMMARY_REQUIRED, 'items', 'item_count', 'invoices'], }, annotations: ANNOTATIONS_READ_ONLY, // Search-only like gnubok_get_invoice: the line-level detail read behind // the delivery and invoicing writes. Reads stay callable on Claude.ai via // gnubok_call_tool; the tools/list budget (payload-size.bench.test.ts) // has no room for its typed line schema in the default catalog. catalogVisibility: 'search', async execute(args, companyId, userId, supabase) { const order = await loadSalesOrderOrThrow(supabase, companyId, args.sales_order_id) const { data: invoiceRows, error: invoiceError } = await supabase .from('invoices') .select('id, invoice_number, status, invoice_date, total, currency') .eq('company_id', companyId) .eq('sales_order_id', order.id) .order('invoice_date', { ascending: true }) .order('id', { ascending: true }) if (invoiceError) throw dbError(invoiceError) const items = (order.items ?? []).map(salesOrderLineOut) return { ...salesOrderSummary(order), source_invoice_id: order.source_invoice_id ?? null, your_reference: order.your_reference ?? null, our_reference: order.our_reference ?? null, notes: order.notes ?? null, default_dimensions: order.default_dimensions ?? {}, confirmed_at: order.confirmed_at ?? null, completed_at: order.completed_at ?? null, cancelled_at: order.cancelled_at ?? null, items, item_count: items.length, invoices: (invoiceRows ?? []).map((inv) => ({ invoice_id: inv.id, invoice_number: inv.invoice_number ?? null, status: inv.status, invoice_date: inv.invoice_date, total: inv.total, currency: inv.currency, })), } }, }, { name: 'gnubok_create_sales_order', keywords: ['kundorder', 'order', 'ny order', 'orderbekräftelse'], title: 'Create Sales Order', description: 'Stage a new kundorder (sales order) as a draft with its lines. Stages for approval: nothing is booked; totals are informational. Confirm it afterwards with gnubok_transition_sales_order, then deliver and invoice from it.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { customer_id: { type: 'string', description: 'Customer UUID' }, items: { type: 'array', items: SALES_ORDER_LINE_INPUT_SCHEMA, description: 'Order lines' }, default_dimensions: { type: 'object', additionalProperties: { type: 'string' }, description: 'Dims bag {sie_dim_no: kod eller namn} applied to every item not setting the key', }, order_date: { type: 'string', description: 'YYYY-MM-DD (default today)' }, requested_delivery_date: { type: 'string', description: 'YYYY-MM-DD' }, currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] }, our_reference: { type: 'string' }, your_reference: { type: 'string' }, notes: { type: 'string' }, dry_run: { type: 'boolean', description: 'Preview without staging' }, idempotency_key: { type: 'string', description: 'UUID for safe retries (24h)' }, }, required: ['customer_id', 'items'], }, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, // Search-only (same footing as the mileage and skattekonto families): the // tools/list budget (payload-size.bench.test.ts) has ~900 tokens of // headroom and the four staged kundorder writes need ~2000 even trimmed. // gnubok_list_sales_orders stays in the default catalog as the entry // point; promoting the writes means demoting other reads first. catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { const customerId = typeof args.customer_id === 'string' ? args.customer_id.trim() : '' const rawItems = Array.isArray(args.items) ? (args.items as StagedInvoiceLineInput[]) : [] if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.') if (rawItems.length === 0) throw new Error('At least one item is required.') const { data: customer, error: custError } = await supabase .from('customers') .select('*') .eq('id', customerId) .eq('company_id', companyId) .maybeSingle() if (custError) throw dbError(custError) if (!customer) throw new Error('Customer not found. Use gnubok_list_customers to find valid IDs.') const currency = ((args.currency as string) || 'SEK') as Currency const today = new Date().toISOString().split('T')[0] // Article prefill with the same rules as gnubok_create_invoice: the // line's own values win, the article fills the rest, and its VAT rate // is adopted only inside the customer's default rate set. const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated, customer.country) const articleIds = Array.from(new Set(rawItems.map((i) => i.article_id).filter((a): a is string => !!a))) const articlesById = new Map() if (articleIds.length > 0) { const { data: articleRows, error: articleError } = await supabase .from('articles') .select('id, name, unit, price_excl_vat, vat_rate, revenue_account, currency, active') .eq('company_id', companyId) .in('id', articleIds) if (articleError) throw dbError(articleError) for (const row of articleRows ?? []) articlesById.set(row.id, row) } const resolvedLines = rawItems.map((item, i) => resolveInvoiceLineFromArticle( item, item.article_id ? articlesById.get(item.article_id) : undefined, currency, adoptableVatRates, i, ), ) // Resolve-don't-select for dimensions (names to registry codes), same // as gnubok_create_invoice; the resolved bags are what gets staged. const defaultDimensions = parseDimensionsArg(args.default_dimensions, 'default_dimensions') const { bags: resolvedDimBags, resolutions: dimensionResolutions } = await resolveDimensionBags( supabase, companyId, [defaultDimensions, ...resolvedLines.map((item, i) => parseDimensionsArg(item.dimensions, `items[${i}].dimensions`))], ) const resolvedDefaultDimensions = resolvedDimBags[0] const items = resolvedLines.map((line, i) => { const bag = resolvedDimBags[i + 1] return { line_type: line.line_type ?? 'product', description: line.description, quantity: line.quantity, unit: line.unit, unit_price: line.unit_price, ...(line.discount_percent != null ? { discount_percent: line.discount_percent } : {}), ...(line.vat_rate != null ? { vat_rate: line.vat_rate } : {}), ...(line.article_id ? { article_id: line.article_id } : {}), ...(line.revenue_account != null ? { revenue_account: line.revenue_account } : {}), ...(bag && Object.keys(bag).length > 0 ? { dimensions: bag } : {}), } }) // Same schema the cookie route validates with, so a payload refused // here is refused identically at the commit boundary. const parsed = CreateSalesOrderSchema.safeParse({ customer_id: customerId, order_date: (args.order_date as string) || today, ...(args.requested_delivery_date ? { requested_delivery_date: args.requested_delivery_date } : {}), currency, ...(args.your_reference ? { your_reference: args.your_reference } : {}), ...(args.our_reference ? { our_reference: args.our_reference } : {}), ...(args.notes ? { notes: args.notes } : {}), ...(resolvedDefaultDimensions && Object.keys(resolvedDefaultDimensions).length > 0 ? { default_dimensions: resolvedDefaultDimensions } : {}), items, }) if (!parsed.success) throwFirstZodIssue(parsed) const params = parsed.data // Preview totals from the exact line math the service stores (net of // discount, öre-exact) including the per-customer VAT gate. const lines = normalizeSalesOrderLines(params.items, customer) if (!lines.ok) throwSalesOrderFailure(lines, 'Cannot create sales order') return stagePendingOperation(supabase, companyId, userId, 'create_sales_order', `Ny kundorder: ${customer.name} ${lines.totals.total} ${currency}`, params as unknown as Record, { customer_name: customer.name, customer_type: customer.customer_type, items: lines.rows.map((row) => ({ line_type: row.line_type, description: row.description, quantity: row.quantity, unit: row.unit, unit_price: row.unit_price, discount_percent: row.discount_percent, vat_rate: row.vat_rate, line_total: row.line_total, article_id: row.article_id, revenue_account: row.revenue_account, dimensions: row.dimensions, })), subtotal: lines.totals.subtotal, vat_amount: lines.totals.vat_amount, total: lines.totals.total, currency, order_date: params.order_date, requested_delivery_date: params.requested_delivery_date ?? null, // Informational: an order is not a verifikat, so no period check. writes_verifikat: false, ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), }, actor, { description: 'Once approved, the order is a draft. Confirm it with gnubok_transition_sales_order (action: confirm) before delivering or invoicing.', tool: 'gnubok_transition_sales_order', args: { action: 'confirm' }, }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, { name: 'gnubok_transition_sales_order', keywords: ['kundorder', 'order', 'bekräfta order', 'makulera order', 'återöppna order'], title: 'Transition Sales Order', description: 'Stage a status change on a kundorder: confirm (draft to confirmed), cancel (draft or confirmed), or reopen (cancelled to draft). Stages for approval. Cancel and reopen are refused while invoices created from the order exist.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { sales_order_id: { type: 'string', description: 'UUID from gnubok_list_sales_orders' }, action: { type: 'string', enum: ['confirm', 'cancel', 'reopen'] }, dry_run: { type: 'boolean', description: 'Preview without staging' }, idempotency_key: { type: 'string', description: 'UUID for safe retries (24h)' }, }, required: ['sales_order_id', 'action'], }, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, // Search-only: see gnubok_create_sales_order. catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { const parsedAction = SalesOrderTransitionSchema.safeParse({ action: args.action }) if (!parsedAction.success) throwFirstZodIssue(parsedAction) const { action } = parsedAction.data const order = await loadSalesOrderOrThrow(supabase, companyId, args.sales_order_id) const rule = SALES_ORDER_TRANSITIONS[action] if (!rule.from.includes(order.status)) { throw new Error( `Cannot ${action} sales order ${order.order_number ?? order.id}: SALES_ORDER_INVALID_STATE. ` + `Status is "${order.status}"; ${action} requires ${rule.from.map((s) => `"${s}"`).join(' or ')}.`, ) } if (action === 'confirm' && !order.customer_id) { throw new Error('Cannot confirm sales order: SALES_ORDER_CUSTOMER_MISSING. Set a customer first.') } if (action === 'cancel' || action === 'reopen') { const open = await hasOpenInvoices(supabase, companyId, order.id) if (!open.ok) throw dbError(open.dbError) if (open.open) { throw new Error( `Cannot ${action} sales order ${order.order_number ?? order.id}: SALES_ORDER_HAS_INVOICES. ` + 'Cancel or credit the invoices created from it first (see gnubok_get_sales_order.invoices).', ) } } const label = order.order_number ?? order.id const titleVerb = action === 'confirm' ? 'Bekräfta' : action === 'cancel' ? 'Makulera' : 'Återöppna' const customerName = (order.customer as { name?: string } | null | undefined)?.name ?? null return stagePendingOperation(supabase, companyId, userId, 'transition_sales_order', `${titleVerb} kundorder ${label}`, { sales_order_id: order.id, action }, { sales_order_id: order.id, order_number: order.order_number ?? null, customer_name: customerName, action, current_status: order.status, new_status: rule.to, total: order.total, currency: order.currency, writes_verifikat: false, }, actor, action === 'confirm' ? { description: 'Once confirmed, register deliveries with gnubok_register_sales_order_delivery or invoice it with gnubok_create_invoice_from_sales_order.', tool: 'gnubok_create_invoice_from_sales_order', args: { sales_order_id: order.id }, } : { description: 'Check the order afterwards with gnubok_get_sales_order.', tool: 'gnubok_get_sales_order', args: { sales_order_id: order.id }, }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, { name: 'gnubok_register_sales_order_delivery', keywords: ['kundorder', 'leverans', 'leverera', 'delleverans', 'levererat antal'], title: 'Register Sales Order Delivery', description: 'Stage delivered quantities on a confirmed kundorder. delivered_qty is CUMULATIVE per line (the new total, not a delta), so retries are safe. Stages for approval; nothing is booked. Sets the delivery date used by invoices created afterwards.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { sales_order_id: { type: 'string', description: 'UUID from gnubok_list_sales_orders' }, delivery_date: { type: 'string', description: 'YYYY-MM-DD (default today)' }, lines: { type: 'array', description: 'Delivered lines; omitted lines are untouched', items: { type: 'object', properties: { sales_order_item_id: { type: 'string', description: 'Line UUID from gnubok_get_sales_order' }, delivered_qty: { type: 'number', description: 'Cumulative delivered total, 0..quantity' }, }, required: ['sales_order_item_id', 'delivered_qty'], }, }, dry_run: { type: 'boolean', description: 'Preview without staging' }, idempotency_key: { type: 'string', description: 'UUID for safe retries (24h)' }, }, required: ['sales_order_id', 'lines'], }, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, // Search-only: see gnubok_create_sales_order. catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { const parsed = RegisterSalesOrderDeliverySchema.safeParse({ ...(args.delivery_date ? { delivery_date: args.delivery_date } : {}), lines: args.lines, }) if (!parsed.success) throwFirstZodIssue(parsed) const input = parsed.data const order = await loadSalesOrderOrThrow(supabase, companyId, args.sales_order_id) if (order.status !== 'confirmed' && order.status !== 'completed') { throw new Error( `Cannot register delivery on sales order ${order.order_number ?? order.id}: SALES_ORDER_INVALID_STATE. ` + `Status is "${order.status}"; confirm it first with gnubok_transition_sales_order.`, ) } // Same per-line guards the service applies at commit (line exists, // text rows carry no quantity, never above the ordered quantity), so // the agent learns about a bad line now instead of after approval. const byId = new Map((order.items ?? []).map((i) => [i.id, i])) const previewLines: Record[] = [] for (const line of input.lines) { const item = byId.get(line.sales_order_item_id) if (!item) { throw new Error( `SALES_ORDER_LINE_NOT_FOUND: line ${line.sales_order_item_id} is not on this order. Read gnubok_get_sales_order for the line ids.`, ) } if (item.line_type === 'text') continue if (line.delivered_qty > item.quantity) { throw new Error( `SALES_ORDER_OVER_DELIVERED: line "${item.description}" has quantity ${item.quantity}, cannot mark ${line.delivered_qty} delivered.`, ) } previewLines.push({ sales_order_item_id: item.id, description: item.description, quantity: item.quantity, unit: item.unit, delivered_before: item.delivered_qty, delivered_after: line.delivered_qty, delta: roundOre(line.delivered_qty - item.delivered_qty), }) } if (previewLines.length === 0) throw new Error('No product lines to deliver: every referenced line is a text row.') const deliveryDate = input.delivery_date ?? new Date().toISOString().split('T')[0] const label = order.order_number ?? order.id const customerName = (order.customer as { name?: string } | null | undefined)?.name ?? null return stagePendingOperation(supabase, companyId, userId, 'register_sales_order_delivery', `Leverans kundorder ${label}`, { sales_order_id: order.id, delivery_date: deliveryDate, lines: input.lines }, { sales_order_id: order.id, order_number: order.order_number ?? null, customer_name: customerName, delivery_date: deliveryDate, lines: previewLines, // Quantities only: no inventory, no verifikat. writes_verifikat: false, }, actor, { description: 'Once approved, invoice the delivered quantities with gnubok_create_invoice_from_sales_order (mode: delivered).', tool: 'gnubok_create_invoice_from_sales_order', args: { sales_order_id: order.id, mode: 'delivered' }, }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, { name: 'gnubok_create_invoice_from_sales_order', keywords: ['kundorder', 'fakturera order', 'delfaktura', 'delfakturera', 'slutfaktura', 'fakturera leverans'], title: 'Create Invoice From Sales Order', description: 'Stage a DRAFT kundfaktura from a confirmed kundorder: mode remaining (default) bills everything left, delivered bills what is delivered but not yet invoiced, or pick lines explicitly (delfaktura). Stages for approval; the number is assigned on send.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { sales_order_id: { type: 'string', description: 'UUID from gnubok_list_sales_orders' }, mode: { type: 'string', enum: ['remaining', 'delivered'], description: 'Line selection when lines is omitted (default remaining)' }, lines: { type: 'array', description: 'Explicit picks (win over mode)', items: { type: 'object', properties: { sales_order_item_id: { type: 'string', description: 'Line UUID from gnubok_get_sales_order' }, quantity: { type: 'number', description: 'Positive, at most remaining_qty' }, }, required: ['sales_order_item_id', 'quantity'], }, }, invoice_date: { type: 'string', description: 'YYYY-MM-DD (default today)' }, due_date: { type: 'string', description: 'YYYY-MM-DD (default from payment terms)' }, dry_run: { type: 'boolean', description: 'Preview without staging' }, idempotency_key: { type: 'string', description: 'UUID for safe retries (24h)' }, }, required: ['sales_order_id'], }, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, // Search-only: see gnubok_create_sales_order. catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { const parsed = CreateInvoiceFromSalesOrderSchema.safeParse({ ...(args.mode ? { mode: args.mode } : {}), ...(args.lines !== undefined ? { lines: args.lines } : {}), ...(args.invoice_date ? { invoice_date: args.invoice_date } : {}), ...(args.due_date ? { due_date: args.due_date } : {}), }) if (!parsed.success) throwFirstZodIssue(parsed) const input = parsed.data const order = await loadSalesOrderOrThrow(supabase, companyId, args.sales_order_id) const label = order.order_number ?? order.id if (order.status !== 'confirmed') { const hint = order.status === 'draft' ? 'Confirm it first with gnubok_transition_sales_order (action: confirm).' : order.status === 'completed' ? 'It is fully invoiced already; see gnubok_get_sales_order.invoices.' : 'A cancelled order cannot be invoiced; reopen and confirm it first.' throw new Error(`Cannot invoice sales order ${label}: SALES_ORDER_INVALID_STATE. Status is "${order.status}". ${hint}`) } if (!order.customer_id) { throw new Error(`Cannot invoice sales order ${label}: SALES_ORDER_CUSTOMER_MISSING. Set a customer first.`) } // The same picker the service runs at commit, against the CURRENT // invoiced quantities: nothing left to bill, or a pick above // remaining_qty, is refused here rather than after approval. const pickedRes = pickLines(order, input) if (!pickedRes.ok) throwSalesOrderFailure(pickedRes, `Cannot invoice sales order ${label}`) const { picked } = pickedRes // Preview line math: computeLineNet + roundOre, the same primitives the // invoice builder uses; the builder is authoritative at commit. let subtotal = 0 let vatAmount = 0 const previewLines = picked.map(({ item, quantity }) => { const remaining = item.remaining_qty ?? Math.max(0, item.quantity - (item.invoiced_qty ?? 0)) const lineTotal = computeLineNet(quantity, item.unit_price, item.discount_percent) const lineVat = roundOre((lineTotal * item.vat_rate) / 100) subtotal = roundOre(subtotal + lineTotal) vatAmount = roundOre(vatAmount + lineVat) return { sales_order_item_id: item.id, description: item.description, quantity, unit: item.unit, unit_price: item.unit_price, discount_percent: item.discount_percent, vat_rate: item.vat_rate, line_total: lineTotal, vat_amount: lineVat, remaining_after: roundOre(remaining - quantity), } }) const total = roundOre(subtotal + vatAmount) const customer = order.customer as (Customer | null | undefined) const invoiceDate = input.invoice_date ?? new Date().toISOString().split('T')[0] let dueDate = input.due_date if (!dueDate) { const due = new Date(invoiceDate) due.setDate(due.getDate() + (customer?.default_payment_terms ?? 30)) dueDate = due.toISOString().split('T')[0] } const anyDelivered = picked.some((p) => p.item.delivered_qty > 0) return stagePendingOperation(supabase, companyId, userId, 'create_invoice_from_sales_order', `Faktura från kundorder ${label}: ${customer?.name ?? ''} ${total} ${order.currency}`.replace(/\s+/g, ' '), { sales_order_id: order.id, ...(input.mode ? { mode: input.mode } : {}), ...(input.lines ? { lines: input.lines } : {}), invoice_date: invoiceDate, due_date: dueDate, }, { sales_order_id: order.id, order_number: order.order_number ?? null, customer_name: customer?.name ?? null, mode: input.lines && input.lines.length > 0 ? 'explicit' : (input.mode ?? 'remaining'), items: previewLines, subtotal, vat_amount: vatAmount, total, currency: order.currency, invoice_date: invoiceDate, due_date: dueDate, // Taxable-event date only when the invoice covers delivered goods. delivery_date: anyDelivered ? (order.last_delivery_date ?? null) : null, creates_draft: true, }, actor, { description: 'Once approved, the invoice exists as a draft linked to the order. Send it with gnubok_send_invoice or use gnubok_mark_invoice_as_sent if delivered outside the system.', tool: 'gnubok_send_invoice', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, // ── Report tools ───────────────────────────────────────────── { name: 'gnubok_get_trial_balance', keywords: ['råbalans', 'saldobalans'], 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: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const period = await resolveReportPeriod(supabase, companyId, args.period_id, 'No fiscal periods found. Categorize some transactions first to auto-create a period.') // 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, period.id, // 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', keywords: ['moms', 'momsrapport', 'momsdeklaration', 'momsredovisning'], 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: ANNOTATIONS_READ_ONLY, // 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', keywords: ['moms', 'momsgranskning'], 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: ANNOTATIONS_READ_ONLY, _meta: { ui: { resourceUri: 'ui://vat-review/app.html' } }, async execute(args, companyId, _userId, supabase) { return computeVatReport(args, companyId, supabase) }, }, { name: 'gnubok_vat_close_check', keywords: ['moms', 'momsavstämning', 'momskontroll'], title: 'VAT Close Check (Momsdeklaration)', description: "Answer 'can I close VAT?' in one call. Returns SKV 4700 rutor, bookkeeping blockers (including unavailable deadlines), and declaration_checks from the same momsdeklaration completeness gate used by the web filing UI. 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: ANNOTATIONS_READ_ONLY, async execute(args, companyId, _userId, supabase) { return computeVatCloseCheck(args, companyId, supabase) }, }, // ── KPI & Income Statement tools ───────────────────────────── { name: 'gnubok_get_kpi_report', keywords: ['nyckeltal'], 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)' }, }, // period_id is the whole surface. Callers have shipped `metric` here for // days at a time (604 rejected calls, 2026-08-24 to 08-31): the empty // object is the example that says there is nothing else to pass. examples: [{}, { period_id: '7c2b...' }], }, outputSchema: { type: 'object' }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const period = await resolveReportPeriod(supabase, companyId, args.period_id, 'No fiscal periods found. Categorize some transactions first.') // Run queries in parallel (same as the KPI API route) const [incomeStatement, trialBalance, arLedger, monthlyBreakdown, paidInvoices] = await Promise.all([ generateIncomeStatement(supabase, companyId, period.id), generateTrialBalance(supabase, companyId, period.id, { closingEntry: 'include' }), generateARLedger(supabase, companyId), generateMonthlyBreakdown(supabase, companyId, period.id), 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', keywords: ['resultaträkning', 'resultatrapport'], title: 'Income Statement (Resultaträkning)', description: 'Income statement (resultaträkning) for a fiscal period or a from_date/to_date range inside it: revenue, expenses, net result. Optional dimensions filter (kostnadsställe/projekt).', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, from_date: { type: 'string', description: 'Start YYYY-MM-DD inside the period' }, to_date: { type: 'string', description: 'End YYYY-MM-DD inside the period' }, dimensions: REPORT_DIMENSIONS_FILTER_SCHEMA, }, }, outputSchema: { type: 'object', properties: { ...DIMENSION_FILTER_OUTPUT_PROPS }, }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const period = await resolveReportPeriod(supabase, companyId, args.period_id, 'No fiscal periods found. Categorize some transactions first.') rejectUnknownArgs(args, ['period_id', 'from_date', 'to_date', 'dimensions']) const range = parseReportRangeArgs(args, period, { from: 'from_date', to: 'to_date' }) const dimFilter = await resolveReportDimensionFilter(supabase, companyId, args.dimensions) const result = await generateIncomeStatement( supabase, companyId, period.id, { ...range, ...(dimFilter.filter ? { dimensions: dimFilter.filter } : {}), }, ) // Echo the effective range, not the fiscal-period bounds. result.period = { start: range.fromDate ?? period.period_start, end: range.toDate ?? 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', keywords: ['faktura betald', 'betalning', 'kundfaktura'], 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: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const invoice = await fetchInvoiceWithCustomer(supabase, companyId, invoiceId) // A quote is an offer, not a claim (parity with the dashboard mark-paid // route, which likewise refuses only quotes: marking a sent proforma paid // is a supported prepayment record with no verifikat). if (invoice.document_type === 'quote') { throw registryError('INVOICE_QUOTE_NOT_PAYABLE') } 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', keywords: ['skicka faktura', 'kundfaktura', 'e-faktura', 'mejla faktura'], 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: ANNOTATIONS_WRITE_OPEN_WORLD, 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 (or SMTP_HOST and SMTP_FROM_EMAIL with EMAIL_PROVIDER=smtp).') } const invoice = await fetchInvoiceWithCustomer(supabase, companyId, invoiceId) 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', keywords: ['fakturaleverans', 'studs', 'kundfaktura'], 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: ANNOTATIONS_READ_ONLY, // 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 dbError(invoiceError) 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 dbError(error) // 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', keywords: ['faktura skickad', 'kundfaktura'], 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: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const invoice = await fetchInvoiceWithCustomer(supabase, companyId, invoiceId) 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', keywords: ['leverantör', 'leverantörer', 'leverantörsregister'], title: 'List Suppliers (Leverantörer)', description: 'List active suppliers (leverantörer) with contact and payment details, sorted by name. include_archived=true adds archived rows.', inputSchema: { type: 'object', additionalProperties: false, properties: { include_archived: { type: 'boolean' } }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { suppliers: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['suppliers', 'count'], }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { // Archived rows (v1 API soft-delete) are hidden by default: same // `archived_at IS NULL` convention and opt-in flag as the v1 list. const includeArchived = args.include_archived === true // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at // 1000 rows. Page on the unique id, then re-sort by name for display. let suppliers: { id: string; name: string }[] try { suppliers = await fetchAllRows<{ id: string; name: string }>(({ from, to }) => { const query = supabase .from('suppliers') .select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country, archived_at') .eq('company_id', companyId) return (includeArchived ? query : query.is('archived_at', null)) .order('id', { ascending: true }) .range(from, to) }) } catch (error) { throw dbError(error) } suppliers.sort((a, b) => a.name.localeCompare(b.name, 'sv') || a.id.localeCompare(b.id)) return { suppliers, count: suppliers.length } }, }, { name: 'gnubok_create_supplier', keywords: ['leverantör', 'ny leverantör'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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', keywords: ['leverantörsfaktura', 'leverantörsfakturor', 'inköpsfaktura'], title: 'List Supplier Invoices', description: 'List supplier invoices (leverantörsfakturor), sorted by due date. Filters: status (to_pay = approved+overdue), supplier_id, supplier_name (substring), date_from/date_to on invoice_date (YYYY-MM-DD).', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['registered', 'approved', 'overdue', 'paid', 'to_pay', 'all'], }, supplier_id: { type: 'string' }, supplier_name: { type: 'string' }, date_from: { type: 'string' }, date_to: { type: 'string' }, 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' }, total_count: { type: 'number' }, has_more: { type: 'boolean' }, }, required: ['invoices', 'count'], }, annotations: ANNOTATIONS_READ_ONLY, 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' // Loud validation: a mistyped filter must never silently degrade to the // UNFILTERED list (the silent-drop failure class arg-guard exists for, // feedback seq 261545). Hosts do not reliably enforce inputSchema types, // so non-string values reach execute() and are rejected here. for (const key of ['supplier_id', 'supplier_name', 'date_from', 'date_to'] as const) { if (args[key] !== undefined && typeof args[key] !== 'string') { throw new Error(`${key} must be a string.`) } } const supplierId = args.supplier_id as string | undefined const supplierName = (args.supplier_name as string | undefined)?.trim() const dateFrom = args.date_from as string | undefined const dateTo = args.date_to as string | undefined if (supplierName === '') { throw new Error('supplier_name must not be blank.') } // Clear message instead of a raw Postgres uuid cast error: an agent may // well pass a supplier's name here; point it at supplier_name instead. if (supplierId && !UUID_RE.test(supplierId)) { throw new Error('supplier_id must be a supplier UUID (suppliers.id). To filter by name, use supplier_name.') } for (const [key, value] of [['date_from', dateFrom], ['date_to', dateTo]] as const) { if (value === undefined) continue // Shape AND calendar validity: '2026-02-30' must fail here with a // clear message, not as a raw Postgres date-cast error downstream. const parsed = new Date(`${value}T00:00:00Z`) if ( !ISO_DATE_RE.test(value) || Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value ) { throw new Error(`${key} must be a valid ISO date (YYYY-MM-DD).`) } } if (dateFrom && dateTo && dateFrom > dateTo) { throw new Error('date_from must not be after date_to.') } // supplier_name resolves server-side to supplier ids: a LITERAL // case-insensitive substring matched in JS over the company's suppliers // (fetchAllRows, same shape as gnubok_list_suppliers). Not ilike: // PostgREST rewrites `*` in like/ilike values to `%`, so a DB-side // pattern silently broadens names containing `*` and there is no // server-side escape for it. Archived suppliers are included on // purpose: their invoices still exist. The match set is bounded: an // unbounded id list fed to .in() below could exceed PostgREST URL // limits, so past the cap the tool fails loudly with a refine hint // instead of degrading. const NAME_MATCH_CAP = 200 let nameMatchedSupplierIds: string[] | undefined if (supplierName) { const needle = supplierName.toLowerCase() let suppliers: { id: string; name: string | null }[] try { suppliers = await fetchAllRows<{ id: string; name: string | null }>(({ from, to }) => supabase .from('suppliers') .select('id, name') .eq('company_id', companyId) .order('id', { ascending: true }) .range(from, to) ) } catch (error) { throw dbError(error) } const matchedIds = suppliers .filter((s) => (s.name ?? '').toLowerCase().includes(needle)) .map((s) => s.id) if (matchedIds.length === 0) { return { invoices: [], count: 0, total_count: 0, has_more: false } } if (matchedIds.length > NAME_MATCH_CAP) { throw new Error( `supplier_name matches more than ${NAME_MATCH_CAP} suppliers; refine the name or use supplier_id.`, ) } nameMatchedSupplierIds = matchedIds } // count: 'exact' so truncation is SIGNALLED (total_count/has_more): the // date filters invite period reconciliation, and a silently capped // listing reads as complete (same contract as gnubok_list_invoices). 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)', { count: 'exact' }) .eq('company_id', companyId) if (status !== 'all') { if (status === 'to_pay') { query = query.in('status', ['approved', 'overdue']) } else { query = query.eq('status', status) } } // Same filter shapes as the v1 supplier-invoices list: eq on // supplier_id, gte/lte on invoice_date. if (supplierId) query = query.eq('supplier_id', supplierId) if (nameMatchedSupplierIds) query = query.in('supplier_id', nameMatchedSupplierIds) if (dateFrom) query = query.gte('invoice_date', dateFrom) if (dateTo) query = query.lte('invoice_date', dateTo) // Tiebreak on the unique id: due_date alone leaves which rows fall past // the cap nondeterministic between identical calls. const { data, error, count } = await query .order('due_date', { ascending: true }) .order('id', { ascending: true }) .limit(limit) if (error) throw dbError(error) const invoices = data ?? [] const totalCount = count ?? invoices.length return { invoices, count: invoices.length, total_count: totalCount, has_more: totalCount > invoices.length, } }, }, // ── Counterparty Templates & Suggestions ───────────────────── { name: 'gnubok_get_counterparty_templates', catalogVisibility: 'search', keywords: ['motpart', 'konteringsmall', 'mallar'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(error) return { templates: (data ?? []).map((t) => ({ ...t, counterparty_name_display: formatCounterpartyName(t.counterparty_name), })), count: data?.length ?? 0, } }, }, { name: 'gnubok_suggest_categories', keywords: ['konteringsförslag', 'kontering', 'kategorisera'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(txError) 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', keywords: ['kontoplan', 'konton', 'baskonton'], 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: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const activeOnly = args.active_only !== false const accountClass = args.account_class as number | undefined // Paginated (fetchAllRows): PostgREST silently caps un-ranged selects at // 1000 rows and a full BAS 2026 chart holds ~1290 accounts. Paging is on // the unique account_number (fetchAllRows ordering invariant); sort_order // is fetched only to restore the BAS canonical display order afterwards, // then stripped so the row shape stays unchanged. interface ChartAccountRow { account_number: string account_name: string account_class: number account_group: string account_type: string normal_balance: string is_active: boolean description: string | null sort_order: number | null } let rows: ChartAccountRow[] try { rows = await fetchAllRows(({ from, to }) => { let query = supabase .from('chart_of_accounts') .select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description, sort_order') .eq('company_id', companyId) if (activeOnly) query = query.eq('is_active', true) if (accountClass !== undefined) query = query.eq('account_class', accountClass) return query.order('account_number', { ascending: true }).range(from, to) }) } catch (error) { throw dbError(error) } // Postgres ordered by sort_order ascending with nulls last; keep that // visible order, tie-breaking on account_number for determinism. rows.sort( (a, b) => (a.sort_order ?? Number.MAX_SAFE_INTEGER) - (b.sort_order ?? Number.MAX_SAFE_INTEGER) || a.account_number.localeCompare(b.account_number) ) const accounts = rows.map(({ sort_order: _sortOrder, ...rest }) => rest) return { accounts, count: accounts.length } }, }, { name: 'gnubok_create_account', keywords: ['kontoplan', 'nytt konto', 'baskonto'], 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).' }, default_vat_treatment: { type: 'string', enum: [...ACCOUNT_VAT_TREATMENTS], description: 'VAT return treatment.', }, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 dbError(existingErr) 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 vatTreatment = args.default_vat_treatment if (vatTreatment !== undefined && vatTreatment !== null && !isAccountVatTreatment(vatTreatment)) { throw new Error('default_vat_treatment is not supported') } const accountClass = Number(accountNumber[0]) if (vatTreatment && !isVatTreatmentAllowedForAccountClass(vatTreatment, accountClass)) { throw new Error('default_vat_treatment is not valid for this account class') } const effectiveVatRate = vatTreatment && vatRate === undefined ? defaultRateForVatTreatment(vatTreatment, accountClass) : vatRate 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: effectiveVatRate, default_vat_treatment: vatTreatment, 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 bookable via gnubok_create_voucher, gnubok_bulk_book_transactions, or gnubok_categorize_transaction with account_override.', tool: 'gnubok_list_accounts', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, } ) }, }, { name: 'gnubok_update_account', keywords: ['kontoplan', 'ändra konto', 'baskonto'], 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).' }, default_vat_treatment: { type: ['string', 'null'], enum: [...ACCOUNT_VAT_TREATMENTS, null], description: 'VAT return treatment.', }, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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, default_vat_treatment, sru_code, is_active') .eq('company_id', companyId) .eq('account_number', accountNumber) .maybeSingle() if (fetchErr) throw dbError(fetchErr) 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 vatTreatment = args.default_vat_treatment if (vatTreatment !== undefined && vatTreatment !== null && !isAccountVatTreatment(vatTreatment)) { throw new Error('default_vat_treatment is not supported') } const accountClass = Number(accountNumber[0]) if (vatTreatment && !isVatTreatmentAllowedForAccountClass(vatTreatment, accountClass)) { throw new Error('default_vat_treatment is not valid for this account class') } const params: Record = { account_number: accountNumber } const changes: Record = {} for (const key of ['account_name', 'description', 'default_vat_code', 'default_vat_rate', 'default_vat_treatment', 'sru_code', 'is_active']) { if (args[key] !== undefined) { params[key] = args[key] changes[key] = args[key] } } if (vatTreatment && vatRate === undefined && current.default_vat_rate == null) { const derivedRate = defaultRateForVatTreatment(vatTreatment, accountClass) params.default_vat_rate = derivedRate changes.default_vat_rate = derivedRate } if (Object.keys(changes).length === 0) { throw new Error('Nothing to update: pass at least one account field.') } 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', keywords: ['dimensioner', 'kostnadsställe', 'projekt', 'resultatenhet'], 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', catalogVisibility: 'search', keywords: ['dimensionsvärden', 'kostnadsställe', 'projekt'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(dimError) 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 dbError(valuesError) 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', keywords: ['kostnadsställe', 'projekt', 'dimensionsvärde'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 dbError(dimError) 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 dbError(existingError) 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', keywords: ['kostnadsställe', 'projekt', 'tagga', 'dimension'], 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 plus status:'posted') 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: ANNOTATIONS_STAGED_WRITE, 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 // Same normalization as gnubok_query_journal: a bare number or string // must narrow the match set, never be silently dropped. This is a bulk // WRITE path (retags up to 500 posted lines), so a dropped account // filter would widen the scope of the retag. const accounts = normalizeAccountList(filters.accounts) if (accounts && accounts.length > 50) { throw new Error('filters.accounts is capped at 50: use account_from/account_to for ranges') } const accountFrom = normalizeAccountNumber(filters.account_from, 'filters.account_from') const accountTo = normalizeAccountNumber(filters.account_to, 'filters.account_to') 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', catalogVisibility: 'search', keywords: ['projektresultat', 'kostnadsställe', 'resultat per projekt'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['balansräkning', 'balansrapport'], title: 'Balance Sheet (Balansräkning)', description: 'Balance sheet (balansräkning) for a fiscal period or as of as_of_date: assets, equity, liabilities with totals + balance check.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default period end)' }, }, }, outputSchema: { type: 'object' }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const period = await resolveReportPeriod(supabase, companyId, args.period_id, 'No fiscal periods found. Create one first.') rejectUnknownArgs(args, ['period_id', 'as_of_date']) const range = parseReportRangeArgs(args, period, { to: 'as_of_date' }) const result = await generateBalanceSheet(supabase, companyId, period.id, { toDate: range.toDate, }) return { period_name: period.name, ...result, // Echo the effective window: cumulative from period start to as_of_date. period: { start: period.period_start, end: range.toDate ?? period.period_end }, } }, }, { name: 'gnubok_get_general_ledger', keywords: ['huvudbok', 'huvudboken'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['verifikat', 'verifikation', 'verifikationer', 'grundbok', 'sök verifikat'], title: 'Query Journal Lines', description: 'Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher, source, status, dimensions, free-text. group_by/group_by_dimension aggregation; include_dimensions returns line bags. Lines + totals over the full match set.', 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 'all' (posted + reversed: totals equal ledger balances). One-leg totals are NOT balances." }, 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 still cover the full match set.' }, }, // Two shapes that cover most ad-hoc questions: an account range over a // period, and a free-text hunt. status defaults to 'all' on purpose. examples: [ { account_from: '4000', account_to: '4999', date_from: '2026-01-01', date_to: '2026-03-31' }, { text: 'Kjell', status: 'posted' }, ], }, 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, amount filter included.' }, 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: 'DB match count before the post-fetch amount filter; null when not applied.' }, 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'], description: 'Always full_match: totals/groups cover ALL matching lines regardless of limit (field kept for older clients).', }, status_filter_warning: { type: 'string', description: 'Set when a one-leg status filter excluded opposite-status entries: totals may not equal account balances.', }, 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 grouping is set; sorted by |net| desc, full-match scope.', }, applied_filters: { type: 'object' }, ...DIMENSION_FILTER_OUTPUT_PROPS, }, required: ['lines', 'total_lines', 'returned_lines', 'totals', 'totals_scope'], }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 100), 500) // Default 'all' (posted + reversed): the same inclusion rule every // report uses (trial balance, GL, SIE export). Storno bookkeeping keeps // both the reversed original and its posted storno on the account, so a // one-leg filter produces sums that are not balances; the old 'posted' // default made a real customer's agent find phantom VAT residuals in a // storno-heavy quarter and demand a revert of correct books. const status = (args.status as string) || 'all' if (status !== 'all' && status !== 'posted' && status !== 'reversed') { throw new Error("status must be 'posted', 'reversed' or 'all'") } // Hosts don't always enforce inputSchema: `accounts: 1630` (a bare // number) or `accounts: "1630"` both reached us as-is. A number has no // `.length`, so the filter was silently skipped while applied_filters // still echoed it; a bare string was spread into its digits by // postgrest-js `.in()` and matched nothing. Normalize every shape to a // string[] and reject anything that isn't an account number. const accounts = normalizeAccountList(args.accounts) const accountFrom = normalizeAccountNumber(args.account_from, 'account_from') const accountTo = normalizeAccountNumber(args.account_to, 'account_to') 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' : '' // Column lists for the two-step entry-lines fetch // (lib/bookkeeping/entry-lines.ts), which EVERY pass uses: the plain // query and both free-text legs. The old `journal_entries!inner` embed // is gone on purpose: PostgREST compiled it into a correlated LATERAL // join that walked every tenant's journal_entry_lines, and the // free-text legs (the last users of it) were the query behind the // daily statement timeouts (SQLSTATE 57014) in production. 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` // Filter set for the two-step entry-lines fetch: entry-level predicates // are plain column filters on journal_entries, line-level ones stay on // journal_entry_lines. Every pass (plain and both text legs) applies // BOTH, so they always describe one match set; the text legs only add // their .ilike() on top. const filterEntriesWithStatus = (q: EntryLinesQuery, st: string): EntryLinesQuery => { let e = q.eq('company_id', companyId) e = st === 'all' ? e.in('status', ['posted', 'reversed']) : e.eq('status', st) 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 filterEntries = (q: EntryLinesQuery): EntryLinesQuery => filterEntriesWithStatus(q, status) 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 } // Wrap a failed DB pass. The raw PostgREST/Postgres message never // reaches the agent (it can name schemas and relations), but a // transient failure (statement timeout 57014, connection drop) must // still be dispatchable: the structured-error layer maps `code: // 'TRANSIENT_ERROR'` to the registry's retryable envelope instead of // the generic "Något gick fel" UNKNOWN_ERROR, and the message tells the // agent what to do about it. const sanitizeDbError = (err: unknown, safeMessage: string): Error => { if (getStructuredError(err).code === 'TRANSIENT_ERROR') { const out = new Error( `${safeMessage}: the query timed out or the database was temporarily unavailable. Retry, or narrow the search with date_from/date_to.` ) as Error & { code: string } out.code = 'TRANSIENT_ERROR' return out } return new Error(safeMessage) } // Free-text search runs as two parallel two-step fetches: one matching // journal_entries.description (entry side), one matching // line_description (line side). PostgREST's flat .or() cannot span the // two tables, so we issue two passes over the SAME filter set and merge // by line id. Each pass pulls its full ilike match set (paginated by the // helper), so totals/groups are exact for text queries too; the old // per-leg window (legLimit/legCapHit) is gone with the embed. const text = (args.text as string | undefined)?.trim() // Full match set for every path, so totals and groups are exact // regardless of `limit`. let fullRows: LineRow[] 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}%` // Leg A: entries whose description matches, then all of their lines // (that pass the line filters). Leg B: every entry in scope, then only // the lines whose line_description matches. Both legs are bounded by // the entry-side filters (company, status, dates, series, source), so // the scan is tenant-scoped and driven from journal_entries; leg B // fetches the same entry id set the plain query would, but only the // matching lines, so it is never more expensive than the equivalent // query without `text`. let byEntry: LineRow[] let byLine: LineRow[] try { ;[byEntry, byLine] = await Promise.all([ fetchEntryLines({ supabase, entryColumns: ENTRY_COLUMNS, lineColumns: LINE_COLUMNS, filterEntries: (q) => filterEntries(q).ilike('description', pattern), filterLines, }), fetchEntryLines({ supabase, entryColumns: ENTRY_COLUMNS, lineColumns: LINE_COLUMNS, filterEntries, filterLines: (q) => filterLines(q).ilike('line_description', pattern), }), ]) } catch (err) { log.warn('query_journal text-search failed', { companyId, userId, error: err instanceof Error ? err.message : String(err), }) throw sanitizeDbError(err, 'Database error while running text search') } // Merge by line id: a line whose entry description AND line // description both match comes back from both legs exactly once. const merged = new Map() for (const row of byEntry) merged.set(row.id, row) for (const row of byLine) { if (!merged.has(row.id)) merged.set(row.id, row) } fullRows = Array.from(merged.values()) } else { // Plain path: ONE two-step fetch 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); 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 sanitizeDbError(err, 'Database error while running journal query') } } // 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. It runs over the full match set BEFORE the // display slice is cut, so a limit of N returns N matching lines (not // N minus whatever the amount filter removed from the first N). 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 fullFiltered = fullRows.filter(passesAmountFilter) const filtered = [...fullFiltered].sort(byDisplayOrder).slice(0, limit) // Totals aggregate over the full match set on every path (text // included): totals_scope is always 'full_match' and stays in the // output for clients that learned to read it. let totalDebit = 0 let totalCredit = 0 for (const r of fullFiltered) { 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 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 fullFiltered) { 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)) } // One-leg status filters get a balance-integrity warning when the // opposite leg exists in range. Entry-level head count only: cheap, and // exact for what the warning claims (excluded entries exist), without // re-running the line fetch. A failed count never fails the query; the // warning is advisory. let statusFilterWarning: string | undefined if (status !== 'all') { const opposite = status === 'posted' ? 'reversed' : 'posted' const { count, error: countError } = await filterEntriesWithStatus( supabase.from('journal_entries').select('id', { count: 'exact', head: true }), opposite ) if (countError) { log.warn('query_journal opposite-status count failed', { companyId, userId, error: countError.message, }) } else if ((count ?? 0) > 0) { statusFilterWarning = `${count} entr${count === 1 ? 'y' : 'ies'} with status '${opposite}' in the filtered range ` + `${count === 1 ? 'is' : 'are'} excluded by status='${status}' (count scoped by date/voucher/` + `source filters only, not by account or dimension filters). Storno bookkeeping keeps both ` + `the reversed original and its storno on the account, so one-leg totals can differ from ` + `account balances. Re-run with status 'all' (the default) for ledger-accurate sums.` } } // The full match set anchors total_lines / truncated / pre-amount count // on every path; `lines` is the first `limit` of it in display order. return { lines, truncated: fullFiltered.length > lines.length, total_lines: fullFiltered.length, returned_lines: lines.length, amount_filter_applied_post_fetch: amountFilterApplied, db_matched_pre_amount_filter: amountFilterApplied ? fullRows.length : null, totals: { debit: Math.round(totalDebit * 100) / 100, credit: Math.round(totalCredit * 100) / 100, net: Math.round((totalDebit - totalCredit) * 100) / 100, }, totals_scope: 'full_match', ...(statusFilterWarning ? { status_filter_warning: statusFilterWarning } : {}), ...(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', keywords: ['kundreskontra', 'kundfordringar'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['leverantörsreskontra', 'leverantörsskulder', 'leverantörsfaktura'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['matcha betalning', 'kundfaktura', 'inbetalning'], 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: ANNOTATIONS_STAGED_WRITE, 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') // Parity with the dashboard match route: proformas, delivery notes and // quotes carry no receivable to settle. if (invoice.document_type && invoice.document_type !== 'invoice') { throw registryError('MATCH_INVOICE_NOT_INVOICE_TYPE') } 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', keywords: ['klumpbetalning', 'fördela betalning', 'matcha betalningar'], 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: ANNOTATIONS_STAGED_WRITE, 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)') } // kind is the key every guard below branches on (direction, required // id, tenant pre-check). With kind absent, none of them fired: an // incoming +50 359 SEK payment against three kundfakturor staged as // allocations_kind "supplier_invoice" with zero invoice checks // (feedback seq 319919). No host validates inputSchema at runtime, so // reject here, and say which id field goes with which kind. for (const [i, a] of allocations.entries()) { if (a.kind !== 'customer_invoice' && a.kind !== 'supplier_invoice') { throw new Error( `allocations[${i}].kind is required: "customer_invoice" (incoming payment, pass invoice_id) ` + `or "supplier_invoice" (outgoing payment, pass supplier_invoice_id)` + (a.kind === undefined ? '' : `; got ${JSON.stringify(a.kind)}`), ) } } 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, document_type') .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)'}`) } // Only fakturor carry a receivable: the RPC gates on status alone, so // a sent proforma or quote must be refused here (parity with the // single-invoice match tool). if (foundRows.some((r) => r.document_type && r.document_type !== 'invoice')) { throw registryError('MATCH_INVOICE_NOT_INVOICE_TYPE') } } 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', keywords: ['koppla transaktion', 'verifikat'], title: 'Link Transaction to Verifikat', // Search-only since the account-keyed gnubok_reconcile_match covers the // same link (one pair) for bank AND skattekonto; kept callable for clients // that already use it, and reachable via gnubok_search_tools. catalogVisibility: 'search', 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['bokför', 'kontera', 'massbokföring', 'banktransaktioner'], title: 'Bulk-Book Transactions', description: 'Bulk-book N bank txs (same date, same direction) into 1 samlingsverifikat (BFL 5 kap 6§). Link txs to an existing posted verifikat, or create one from caller lines. Each tx posts its cash-account line. 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: ANNOTATIONS_STAGED_WRITE, 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, description, merchant_name') .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 } // The staged kontering, named for the approver. Same shape and account- // name lookup as gnubok_create_voucher's preview: the approval card is // the human-in-the-loop control on an irreversible BFL posting, and a // card that shows "-720, 2 tx, expense" without the debit/credit lines // is compatible with both a correct booking and a wrong one. The // executor's RPC posts new_entry.lines verbatim, so this preview is // exactly what gets committed. Nothing beyond what create_voucher // already exposes: BAS account + name, amounts, and the agent-authored // line text; still no per-tx descriptions or counterparty identifiers. let previewLines: Array> | null = null if (stagedNewEntry) { const stagedLines = stagedNewEntry.lines as Array> const accountNumbers = [...new Set(stagedLines.map((l) => String(l.account_number)))] const { data: accountRows } = await supabase .from('chart_of_accounts') .select('account_number, account_name') .eq('company_id', companyId) .in('account_number', accountNumbers) const accountNames = new Map() for (const a of accountRows || []) { accountNames.set(String(a.account_number), (a.account_name as string) ?? '') } previewLines = stagedLines.map((l) => { const accountNumber = String(l.account_number) return { account_number: accountNumber, account_name: accountNames.get(accountNumber) ?? getBASReference(accountNumber)?.account_name ?? null, debit_amount: Number(l.debit_amount) || 0, credit_amount: Number(l.credit_amount) || 0, line_description: (l.line_description as string | undefined) ?? null, } }) } // The queue title is what a reviewer approves from (often on a phone): // "Samlingsverifikation: 1 transaktioner 2026-07-22" carried no amount, // direction or counterparty, so the approver could not tell what they // were authorising (feedback seq 261545). Same per-tx text the // categorize titles already carry; preview_data stays aggregate-only. const batchTotal = txs.reduce((sum, t) => sum + Number(t.amount), 0) const batchAmount = `${direction === 'income' ? '+' : '-'}` + `${Math.abs(batchTotal).toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ` + `${txs[0]!.currency ?? 'SEK'}` const batchLead = String(txs[0]!.merchant_name || txs[0]!.description || '').trim().slice(0, 40) const batchRest = txIds.length > 1 ? ` (+${txIds.length - 1} till)` : '' const batchTitle = existingJeId ? `Länka ${txIds.length} transaktioner ${batchAmount} ${txDate} till verifikat: ${batchLead}${batchRest}` : `Samlingsverifikation ${batchAmount} ${txDate}: ${batchLead}${batchRest}` return stagePendingOperation(supabase, companyId, userId, 'bulk_book_transactions', batchTitle, { tx_ids: txIds, existing_journal_entry_id: existingJeId, new_entry: stagedNewEntry, }, // GDPR Art.25: preview_data carries aggregate counts, the shared // date/direction/currency, and the staged kontering (see previewLines // above): no per-tx descriptions, no counterparty IDs. Same privacy- // by-design rationale as gnubok_link_transaction_to_journal_entry. { tx_count: txIds.length, tx_date: txDate, tx_sum: txSum, currency: txs[0]!.currency ?? 'SEK', direction, mode: existingJeId ? 'link_existing' : 'create_new', ...(previewLines ? { entry_description: (stagedNewEntry as Record).description ?? null, 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 } : {}), }, 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', keywords: ['bokför', 'inkorg', 'kvitton', 'underlag'], 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, in the transaction's currency (booked in SEK); 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: ANNOTATIONS_STAGED_WRITE, 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)') // Presence + enum guard at the boundary (hosts don't always enforce // inputSchema `required`/`enum`): without it a missing or unknown // category was staged as-is and only rejected at approval time. const category = typeof args.category === 'string' ? args.category.trim() : '' if (!category) { throw new Error(`category is required. Valid categories: ${VALID_CATEGORIES.join(', ')}`) } if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) { throw new Error(`Invalid category "${category}". Valid categories: ${VALID_CATEGORIES.join(', ')}`) } 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, 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, 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', keywords: ['verifikat', 'kundfaktura', 'koppla faktura'], 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: ANNOTATIONS_READ_ONLY, 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, document_type, 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') // Same gate as gnubok_link_invoice_to_voucher: proformas, delivery // notes and quotes carry no receivable, so there is nothing to match. if (invoice.document_type && invoice.document_type !== 'invoice') { throw registryError('MATCH_INVOICE_NOT_INVOICE_TYPE') } 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', keywords: ['koppla faktura', 'verifikat', 'kundfaktura'], 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: ANNOTATIONS_STAGED_WRITE, 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, document_type, 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') // Parity with the dashboard match route: proformas, delivery notes and // quotes carry no receivable to link a payment voucher to. if (invoice.document_type && invoice.document_type !== 'invoice') { throw registryError('MATCH_INVOICE_NOT_INVOICE_TYPE') } 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', keywords: ['verifikat', 'koppla'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['koppla leverantörsfaktura', 'verifikat'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['automatchning', 'matcha period', 'stäm av månaden'], 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', keywords: ['räkenskapsår', 'bokföringsår', 'perioder'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(error) 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', keywords: ['avstämning', 'skattekonto', 'bankavstämning', 'stäm av'], title: 'Reconciliation Status', description: 'Reconciliation bridge. account_key: "skattekonto", "bank:" or "manual:" (any other balance account, see its manual block). Without it: legacy bank status for account_number. Judge on unexplained_difference.', inputSchema: { type: 'object', additionalProperties: false, properties: { account_key: { type: 'string', description: '"skattekonto", "bank:" or "manual:"; for manual keys date_to is the balansdag.', }, 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: 'Legacy: BAS code, default "1930". Ignored when account_key is set.', }, }, }, outputSchema: { type: 'object' }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const dateFrom = args.date_from as string | undefined const dateTo = args.date_to as string | undefined const accountKey = args.account_key as string | undefined if (accountKey) { const status = await getAccountStatus(supabase, companyId, accountKey, { windowFrom: dateFrom ?? null, windowTo: dateTo ?? null, }) if (!status) throw new Error(`Unknown account_key "${accountKey}" for this company`) // The skattekonto engine also returns its item lists; this tool is the // bridge. Items live in gnubok_list_reconciliation_items. const { items: _items, ...rest } = status as typeof status & { items?: unknown } void _items return rest } // 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 }, }, { name: 'gnubok_list_reconciliation_items', keywords: ['avstämning', 'skattekonto', 'avstämningsposter'], title: 'Reconciliation Items', description: 'Rows behind one account\'s reconciliation bridge, by bucket: side, qualified id, amount, proposal with confidence + reasons, allowed actions. Link via gnubok_reconcile_match.', inputSchema: { type: 'object', additionalProperties: false, properties: { account_key: { type: 'string', description: '"skattekonto" or "bank:".' }, bucket: { type: 'string', enum: ['proposed', 'unmatched_external', 'unmatched_ledger', 'matched', 'ignored', 'upcoming'], }, date_from: { type: 'string', description: 'YYYY-MM-DD; scopes lists, never counts' }, date_to: { type: 'string', description: 'YYYY-MM-DD' }, limit: { type: 'number', description: 'Default 50, max 200' }, offset: { type: 'number' }, }, required: ['account_key'], }, outputSchema: paginatedSchema('items', { type: 'object', properties: { item_id: { type: 'string' }, item_type: { type: 'string' }, side: { type: 'string' }, bucket: { type: 'string' }, amount: { type: 'number' }, proposal: { type: ['object', 'null'] }, actions: { type: 'array', items: { type: 'string' } }, }, }), annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const accountKey = args.account_key as string const result = await listAccountItems(supabase, companyId, accountKey, { bucket: args.bucket as ReconciliationItemBucket | undefined, windowFrom: (args.date_from as string | undefined) ?? null, windowTo: (args.date_to as string | undefined) ?? null, limit: args.limit as number | undefined, offset: args.offset as number | undefined, }) if (!result) throw new Error(`Unknown account_key "${accountKey}" for this company`) return result }, }, { name: 'gnubok_reconcile_match', keywords: ['avstämning', 'skattekonto', 'matcha'], title: 'Reconcile: Link Pairs', description: 'Link outside rows (bank or skattekonto) to existing verifikat on one account; no new bokföring. Pass pairs, or use_proposals to apply the persisted proposals. Stages. dry_run previews.', // Default catalog since E2E #12: the onboarding efterkontroll instructs // matching SIE-covered bank rows against existing verifikat, and // Claude.ai cannot call search-only tools: the agent misread the // uncallable tool as a missing reconciliation:write scope and punted the // whole matching step to the web app. inputSchema: { type: 'object', additionalProperties: false, properties: { account_key: { type: 'string', description: '"skattekonto" or "bank:"' }, pairs: { type: 'array', description: 'Rows against one verifikat, or (bank) one row against several', items: { type: 'object', additionalProperties: false, properties: { external_ids: { type: 'array', items: { type: 'string' } }, journal_entry_ids: { type: 'array', items: { type: 'string' } }, allocations: { type: 'array', description: 'Bank 1:N: signed slice per verifikat, summing to the row', items: { type: 'object', additionalProperties: false, properties: { journal_entry_id: { type: 'string' }, amount: { type: 'number' } }, required: ['journal_entry_id', 'amount'], }, }, }, required: ['external_ids', 'journal_entry_ids'], }, }, use_proposals: { type: 'boolean' }, confidence_threshold: { type: 'number', description: 'Default 0.9' }, dry_run: { type: 'boolean' }, idempotency_key: { type: 'string' }, }, required: ['account_key'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const accountKey = args.account_key as string const pairs = (args.pairs as | Array<{ external_ids: string[] journal_entry_ids: string[] allocations?: Array<{ journal_entry_id: string; amount: number }> }> | undefined) ?? [] const useProposals = args.use_proposals === true if (pairs.length === 0 && !useProposals) { throw new Error('Pass pairs, or use_proposals: true') } const confidenceThreshold = typeof args.confidence_threshold === 'number' ? (args.confidence_threshold as number) : 0.9 // Resolve and validate at stage time so the reviewer sees exactly which // pairs will be linked; the commit executor re-validates every pair. const preview = await matchPairs( supabase, companyId, userId, accountKey, { pairs, use_proposals: useProposals, confidence_threshold: confidenceThreshold }, { dryRun: true }, ) if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`) // Rebuild the staged pairs from the preview. The dry run flattens every // pair into one link per outside row, so the grouping must be put back: // the links of an N:1 pair (several rows, one verifikat, no // allocated_amount) fold into ONE pair on their verifikat, and the links // of a bank 1:N split (one row, allocated_amount per verifikat) fold into // ONE pair with explicit allocations. Staging them as N separate 1:1 // pairs made the executor ask each Skatteverket row alone to settle the // whole 1630 verifikat: PAIR_NOT_CLOSED on every "Avdragen skatt" + // "Arbetsgivaravgift" pair whose sum matched exactly (feedback seq // 292682, 36 rows / 18 verifikat). const resolvedPairs: Array<{ external_ids: string[] journal_entry_ids: string[] allocations?: Array<{ journal_entry_id: string; amount: number }> }> = [] const rowsByEntry = new Map() const splitByRow = new Map>() for (const a of preview.applied) { if (typeof a.allocated_amount === 'number') { const slices = splitByRow.get(a.external_id) ?? [] slices.push({ journal_entry_id: a.journal_entry_id, amount: a.allocated_amount }) splitByRow.set(a.external_id, slices) continue } const rows = rowsByEntry.get(a.journal_entry_id) ?? [] if (!rows.includes(a.external_id)) rows.push(a.external_id) rowsByEntry.set(a.journal_entry_id, rows) } for (const [journalEntryId, externalIds] of rowsByEntry) { resolvedPairs.push({ external_ids: externalIds, journal_entry_ids: [journalEntryId] }) } for (const [externalId, slices] of splitByRow) { resolvedPairs.push({ external_ids: [externalId], journal_entry_ids: slices.map((s) => s.journal_entry_id), allocations: slices, }) } if (resolvedPairs.length === 0) { // The dry run's skipped list holds the actual reason; without it the // agent saw only "No linkable pairs" (feedback seq 292682). const reasons = preview.skipped .slice(0, 5) .map((sk) => `${sk.code}: ${sk.message}`) throw new Error( 'No linkable pairs: nothing to stage' + (reasons.length ? `. Skipped: ${reasons.join(' | ')}` : ''), ) } return stagePendingOperation( supabase, companyId, userId, 'reconciliation_match', `Koppla ${resolvedPairs.length} rad(er) på ${accountKey}`, { account_key: accountKey, pairs: resolvedPairs }, { account_key: accountKey, pair_count: resolvedPairs.length, pairs: resolvedPairs, skipped_at_stage: preview.skipped, source: useProposals ? 'proposals' : 'explicit', }, actor, { description: 'After approval, re-read the bridge to confirm the residual.', tool: 'gnubok_get_reconciliation_status', args: { account_key: accountKey }, }, { dryRun: args.dry_run === true, idempotencyKey: args.idempotency_key as string | undefined, }, ) }, }, { name: 'gnubok_reconcile_unmatch', keywords: ['avstämning', 'ta bort koppling', 'skattekonto'], title: 'Reconcile: Unlink', description: 'Remove the link between one outside row (bank transaction or skattekonto row) and its verifikat on an account. The verifikat is untouched. Stages (low risk).', catalogVisibility: 'search', inputSchema: { type: 'object', additionalProperties: false, properties: { account_key: { type: 'string', description: '"skattekonto" or "bank:".' }, external_id: { type: 'string', description: 'The linked outside row id (transaction_id or skattekonto_transaction_id).' }, dry_run: { type: 'boolean' }, idempotency_key: { type: 'string' }, }, required: ['account_key', 'external_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, async execute(args, companyId, userId, supabase, actor) { const accountKey = args.account_key as string const externalId = args.external_id as string if (!parseAccountKey(accountKey)) throw new Error(`Invalid account_key "${accountKey}"`) return stagePendingOperation( supabase, companyId, userId, 'reconciliation_unmatch', `Koppla bort rad ${externalId} på ${accountKey}`, { account_key: accountKey, external_id: externalId }, { account_key: accountKey, external_id: externalId }, actor, { description: 'After approval, the row is back in the open buckets.', tool: 'gnubok_list_reconciliation_items', args: { account_key: accountKey, bucket: 'unmatched_external' }, }, { dryRun: args.dry_run === true, idempotencyKey: args.idempotency_key as string | undefined, }, ) }, }, { name: 'gnubok_reconcile_signoff', keywords: ['avstämning', 'avstämt', 'signera avstämning'], title: 'Reconcile: Sign off', description: 'Mark one account (skattekonto, bank: or manual:) as reconciled through a date ("avstämt t.o.m."). Refused unless unexplained_difference is 0, or force + note. Manual accounts without a specification take external_balance (underlag, ledger sign). Stages; dry_run previews.', catalogVisibility: 'search', inputSchema: { type: 'object', additionalProperties: false, properties: { account_key: { type: 'string', description: '"skattekonto", "bank:" or "manual:".' }, through_date: { type: 'string', description: 'Inclusive YYYY-MM-DD the account is reconciled through (not in the future; not past the skattekonto snapshot).' }, note: { type: 'string', description: 'Free text. Required with force.' }, force: { type: 'boolean', description: 'Sign despite an unexplained difference or an unknown outside balance. Needs note.' }, external_balance: { type: 'number', description: 'Manual accounts without a system specification only: balance per underlag, ledger sign (liabilities negative).' }, dry_run: { type: 'boolean' }, idempotency_key: { type: 'string' }, }, required: ['account_key', 'through_date'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const accountKey = args.account_key as string const throughDate = args.through_date as string if (!parseAccountKey(accountKey)) throw new Error(`Invalid account_key "${accountKey}"`) // Policy runs now (dry run of the sign-off) so a refusal surfaces here, // not at approval time; the executor re-runs it when the user approves. const preview = await signOffAccount( supabase, companyId, userId, accountKey, { through_date: throughDate, note: (args.note as string | undefined) ?? null, force: args.force === true, external_balance: typeof args.external_balance === 'number' ? args.external_balance : null, }, { dryRun: true }, ) if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`) const previewData: Record = preview.dry_run ? { ...preview.would_sign } : { account_key: accountKey, through_date: throughDate } return stagePendingOperation( supabase, companyId, userId, 'reconciliation_signoff', `Markera ${accountKey} som avstämt t.o.m. ${throughDate}`, { account_key: accountKey, through_date: throughDate, note: (args.note as string | undefined) ?? null, force: args.force === true, external_balance: typeof args.external_balance === 'number' ? args.external_balance : null, }, previewData, actor, { description: 'After approval, the account shows "avstämt t.o.m." in the Avstämning page and on its status.', tool: 'gnubok_get_reconciliation_status', args: { account_key: accountKey }, }, { dryRun: args.dry_run === true, idempotencyKey: args.idempotency_key as string | undefined, }, ) }, }, { name: 'gnubok_reconcile_residual', keywords: ['avstämning', 'bankavgift', 'ränta', 'öresavrundning', 'restpost'], title: 'Reconcile: Book Residual and Link', description: 'Close a near-match on a bank account in one step: link 1..50 bank tx to one verifikat and book the small difference as bank fee (6570), interest (8410/8310) or rounding (3740). Bank only; refused at 0, above the cap, or wrong direction. Stages; dry_run previews.', catalogVisibility: 'search', inputSchema: { type: 'object', additionalProperties: false, properties: { account_key: { type: 'string', description: '"bank:" (skattekonto is refused: Skatteverket posts ränta and avgifter as rows of their own).' }, external_ids: { type: 'array', items: { type: 'string' }, description: 'transaction_id list (1..50) that together settle the verifikat except for the residual.' }, journal_entry_id: { type: 'string', description: 'The verifikat the transactions belong to.' }, kind: { type: 'string', enum: ['bank_fee', 'interest_expense', 'interest_income', 'rounding'], description: 'What the difference is. bank_fee / interest_expense: money left the bank unbooked; interest_income: money arrived unbooked; rounding: either way.' }, entry_date: { type: 'string', description: 'YYYY-MM-DD for the residual verifikat. Default: the latest transaction date.' }, description: { type: 'string', description: 'Verifikat text. Default per kind.' }, dry_run: { type: 'boolean' }, idempotency_key: { type: 'string' }, }, required: ['account_key', 'external_ids', 'journal_entry_id', 'kind'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const accountKey = args.account_key as string const externalIds = args.external_ids as string[] const journalEntryId = args.journal_entry_id as string const kind = args.kind as 'bank_fee' | 'rounding' | 'interest_income' | 'interest_expense' if (!parseAccountKey(accountKey)) throw new Error(`Invalid account_key "${accountKey}"`) if (!Array.isArray(externalIds) || externalIds.length === 0 || externalIds.length > 50) { throw new Error('external_ids must hold 1..50 transaction ids') } // Policy runs now (dry run of the booking) so a refusal (zero, above the // cap, wrong direction, skattekonto) surfaces here, not at approval time; // the executor recomputes the residual when the user approves. const input = { external_ids: externalIds, journal_entry_id: journalEntryId, kind, entry_date: args.entry_date as string | undefined, description: args.description as string | undefined, } const preview = await bookResidualAndLink(supabase, companyId, userId, accountKey, input, { dryRun: true }) if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`) if (!preview.dry_run) throw new Error('Unexpected live result from a dry run') const wouldBook = preview.would_book return stagePendingOperation( supabase, companyId, userId, 'reconciliation_residual', `Bokför restpost ${wouldBook.residual_amount} ${wouldBook.currency} (${kind}) på ${accountKey} och koppla ${externalIds.length} rad(er)`, { account_key: accountKey, ...input }, { ...wouldBook, account_key: accountKey, transaction_count: externalIds.length, max_amount: RESIDUAL_MAX_AMOUNT }, actor, { description: 'After approval, re-read the bridge: the selection is matched and the residual verifikat anchors on the first transaction.', tool: 'gnubok_get_reconciliation_status', args: { account_key: accountKey }, }, { dryRun: args.dry_run === true, idempotencyKey: args.idempotency_key as string | undefined, }, ) }, }, { name: 'gnubok_book_skattekonto_row', title: 'Book Skattekonto Row', description: 'Book one settled skattekonto row as a posted verifikat: 1630 against the counter account matched from skattekonto rules. Refused for already-booked, ignored, upcoming or rule-less rows. Stages; booking happens at approval. dry_run previews.', catalogVisibility: 'search', inputSchema: { type: 'object', additionalProperties: false, properties: { skattekonto_transaction_id: { type: 'string', description: 'The skattekonto_transactions row id (from the skattekonto reconciliation bridge).', }, dry_run: { type: 'boolean' }, idempotency_key: { type: 'string' }, }, required: ['skattekonto_transaction_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.skattekonto_transaction_id if (typeof transactionId !== 'string' || transactionId.length === 0) { throw new Error('skattekonto_transaction_id is required') } const { data: row } = await supabase .from('skattekonto_transactions') .select('id, transaktionsdatum, transaktionstext, belopp_skatteverket, status, is_ignored, journal_entry_id') .eq('id', transactionId) .eq('company_id', companyId) .maybeSingle() if (!row) throw new Error('Skattekonto-transaktionen hittades inte.') const tx = row as SkattekontoStageRow // Same gates the commit path enforces (requireSettled batch booking), // surfaced at stage time so the reviewer never approves a doomed op. if (tx.journal_entry_id) throw new Error('Transaktionen är redan bokförd.') if (tx.is_ignored) { throw new Error('Transaktionen är ignorerad. Återställ den innan du bokför.') } if (tx.status !== 'booked') { throw new Error('Händelsen är inte genomförd hos Skatteverket ännu och kan inte bokföras.') } const [enriched] = await attachBookingSuggestions(supabase, companyId, [tx]) if (!enriched.booking_suggestion) { throw new Error( enriched.booking_gate === 'requires_employer' ? 'Ingen motkontoregel: raden kräver arbetsgivarregistrering (troligen privat A-skatt). Bokför den manuellt.' : 'Ingen motkontoregel matchade raden. Bokför den manuellt med gnubok_create_voucher.', ) } return stagePendingOperation( supabase, companyId, userId, 'book_skattekonto_row', `Bokför skattekontohändelse: ${tx.transaktionstext}`, { transaction_id: tx.id }, { skattekonto_transaction_id: tx.id, transaction_date: tx.transaktionsdatum, transaction_text: tx.transaktionstext, // Mirrors the exact verifikat text bokforSkattekontoTransaction // writes, so the reviewer sees what lands in the ledger (motpart // Skatteverket is carried in the text + the Skatteverket-id note). verifikat_description: `Skattekonto: ${tx.transaktionstext}`, amount: Number(tx.belopp_skatteverket), skattekonto_account: SKATTEKONTO_ACCOUNT, suggested_counter_account: enriched.booking_suggestion.account, suggested_counter_account_name: enriched.booking_suggestion.account_name, rule_label: enriched.booking_suggestion.label, }, actor, { description: 'After approval the row lands as a posted verifikat. Re-read the skattekonto bridge to confirm it shows as booked.', tool: 'gnubok_get_reconciliation_status', args: { account_key: 'skattekonto' }, }, { dryRun: args.dry_run === true, idempotencyKey: args.idempotency_key as string | undefined, dateForPeriodCheck: tx.transaktionsdatum, }, ) }, }, { name: 'gnubok_book_skattekonto_rows', title: 'Book Skattekonto Rows (Batch)', description: 'Book up to 200 settled skattekonto rows as posted verifikat (1630 + rule-matched counter account per row). Unbookable rows (already booked, ignored, upcoming, no rule) are skipped and listed in the preview. Stages; booking happens at approval. dry_run previews.', catalogVisibility: 'search', inputSchema: { type: 'object', additionalProperties: false, properties: { skattekonto_transaction_ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 200, description: 'skattekonto_transactions row ids to book (duplicates are ignored).', }, dry_run: { type: 'boolean' }, idempotency_key: { type: 'string' }, }, required: ['skattekonto_transaction_ids'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const rawIds = args.skattekonto_transaction_ids if (!Array.isArray(rawIds) || rawIds.length === 0) { throw new Error('skattekonto_transaction_ids is required (non-empty array)') } const ids = [ ...new Set(rawIds.filter((v): v is string => typeof v === 'string' && v.length > 0)), ] if (ids.length === 0 || ids.length > 200) { throw new Error('skattekonto_transaction_ids must contain 1-200 row ids') } const { data: rows } = await supabase .from('skattekonto_transactions') .select('id, transaktionsdatum, transaktionstext, belopp_skatteverket, status, is_ignored, journal_entry_id') .in('id', ids) .eq('company_id', companyId) const found = (rows ?? []) as SkattekontoStageRow[] const foundIds = new Set(found.map((r) => r.id)) const skipped: Array<{ skattekonto_transaction_id: string; reason: string }> = [] for (const id of ids) { if (!foundIds.has(id)) { skipped.push({ skattekonto_transaction_id: id, reason: 'TRANSACTION_NOT_FOUND' }) } } // Same per-row gates as the single-row tool; blocked rows become // skipped-with-reason preview data instead of aborting the batch. const enriched = await attachBookingSuggestions(supabase, companyId, found) const bookable: typeof enriched = [] for (const r of enriched) { const reason = r.journal_entry_id ? 'ALREADY_BOOKED' : r.is_ignored ? 'ROW_IGNORED' : r.status !== 'booked' ? 'NOT_SETTLED' : !r.booking_suggestion ? 'NO_COUNTER_ACCOUNT' : null if (reason) { skipped.push({ skattekonto_transaction_id: r.id, reason }) continue } bookable.push(r) } if (bookable.length === 0) { const reasons = [...new Set(skipped.map((s) => s.reason))].join(', ') throw new Error(`Inga bokförbara rader: alla ${ids.length} hoppades över (${reasons}).`) } const bookableIds = bookable.map((r) => r.id) // Oldest row first: lock conflicts live in the past, so the period // check should probe the earliest affärshändelse date in the batch. const earliestDate = bookable.map((r) => r.transaktionsdatum).sort()[0] const totalAmount = Math.round(bookable.reduce((sum, r) => sum + Number(r.belopp_skatteverket), 0) * 100) / 100 return stagePendingOperation( supabase, companyId, userId, 'book_skattekonto_rows', `Bokför ${bookableIds.length} skattekontohändelser`, { ids: bookableIds }, { row_count: bookableIds.length, total_amount: totalAmount, skattekonto_account: SKATTEKONTO_ACCOUNT, rows: bookable.slice(0, 50).map((r) => ({ skattekonto_transaction_id: r.id, transaction_date: r.transaktionsdatum, transaction_text: r.transaktionstext, verifikat_description: `Skattekonto: ${r.transaktionstext}`, amount: Number(r.belopp_skatteverket), suggested_counter_account: r.booking_suggestion?.account ?? null, rule_label: r.booking_suggestion?.label ?? null, })), ...(bookable.length > 50 ? { rows_truncated: true } : {}), ...(skipped.length > 0 ? { skipped } : {}), }, actor, { description: 'After approval each row lands as its own posted verifikat. Re-read the skattekonto bridge to confirm.', tool: 'gnubok_get_reconciliation_status', args: { account_key: 'skattekonto' }, }, { dryRun: args.dry_run === true, idempotencyKey: args.idempotency_key as string | undefined, dateForPeriodCheck: earliestDate, }, ) }, }, { name: 'gnubok_list_cash_accounts', keywords: ['bankkonto', 'kassakonto', 'bankkonton', 'likvidkonton', 'kassa'], title: 'List Cash Accounts', description: 'List the company bank/cash accounts (cash_accounts): BAS ledger, currency, IBAN, primary flag, bank-reported balance (booked + available, with balance_updated_at). Use cash_account_id to filter transaction listings; use for "how much money is in the bank".', inputSchema: { type: 'object', additionalProperties: false, properties: { enabled_only: { type: 'boolean', description: 'Only accounts that sync (default false)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { cash_accounts: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { cash_account_id: { type: 'string' }, ledger_account: { type: 'string', description: 'BAS account, e.g. "1930"' }, name: { type: ['string', 'null'] }, currency: { type: 'string' }, iban: { type: ['string', 'null'] }, is_primary: { type: 'boolean' }, enabled: { type: 'boolean' }, source: { type: 'string', enum: ['enable_banking', 'manual', 'sie_import'] }, balance: { type: ['number', 'null'], description: 'Bank-reported booked balance as of balance_updated_at; null for manual accounts or before the first sync. NOT the bookkept 19xx balance.', }, available_balance: { type: ['number', 'null'], description: 'Bank-reported available balance; null when the bank reports no available type.', }, balance_updated_at: { type: ['string', 'null'], description: 'ISO timestamp of the last balance fetch (PSD2 quota: refreshed at most every 12h).', }, }, required: ['cash_account_id', 'ledger_account', 'name', 'currency', 'iban', 'is_primary', 'enabled', 'source', 'balance', 'available_balance', 'balance_updated_at'], }, }, count: { type: 'number' }, }, required: ['cash_accounts', 'count'], }, annotations: ANNOTATIONS_READ_ONLY, // Search-only: a discovery helper for the transaction listings and the // reconciliation tool, not part of the default catalog (tools/list budget). catalogVisibility: 'search', async execute(args, companyId, _userId, supabase) { const rows = await listCashAccountsForCompany(supabase, companyId, { enabledOnly: args.enabled_only === true, }) const cashAccounts = rows.map((row) => ({ cash_account_id: row.id, ledger_account: row.ledger_account, name: row.name ?? null, currency: row.currency, iban: row.iban ?? null, is_primary: row.is_primary === true, enabled: row.enabled !== false, source: row.source, balance: row.balance ?? null, available_balance: row.available_balance ?? null, balance_updated_at: row.balance_updated_at ?? null, })) return { cash_accounts: cashAccounts, count: cashAccounts.length } }, }, // ── Document Inbox Tools ──────────────────────────────────── { name: 'gnubok_create_document_upload', keywords: ['ladda upp', 'underlag', 'kvitto', 'dokument'], 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'], // Step 1 of 2: PUT the bytes to upload_url, then complete with the SAME // upload_id and file_name. examples: [{ file_name: 'kvitto-sl-2026-03-12.pdf' }], }, 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: ANNOTATIONS_STAGED_WRITE, 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, ) // Served from the app origin: agent sandboxes (Claude Desktop) only // reach the MCP host, not .supabase.co. See storage-proxy.ts. return { upload_id: reservation.uploadId, upload_url: toSameOriginStorageUrl(reservation.signedUrl), expires_at: reservation.expiresAt, } }, }, { name: 'gnubok_complete_document_upload', keywords: ['ladda upp', 'underlag', 'dokument'], 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'], // Step 2 of 2: same upload_id and file_name as gnubok_create_document_upload. examples: [{ upload_id: 'f00d...', file_name: 'kvitto-sl-2026-03-12.pdf' }], }, 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', 'null'] }, }, required: ['document_id', 'inbox_item_id', 'status'], }, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, 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, undefined, { extractionOwner: 'invoice-inbox' }, ) return createDocumentInboxItem( supabase, companyId, userId, completed.document.id, fileName, mimeType, Buffer.from(completed.buffer), uploadId, ) }, }, { name: 'gnubok_upload_document', keywords: ['ladda upp', 'kvitto', 'underlag', 'dokument', 'bifoga'], 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', 'null'] }, }, required: ['document_id', 'inbox_item_id', 'status'], }, annotations: ANNOTATIONS_STAGED_WRITE, 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', extractionOwner: 'invoice-inbox' }) return createDocumentInboxItem( supabase, companyId, userId, doc.id, fileName, mimeType, buffer, ) }, }, { name: 'gnubok_list_inbox_items', keywords: ['inkorg', 'kvitto', 'kvitton', 'underlag'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(error) 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', keywords: ['inkorg', 'kvitto', 'underlag'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(error) if (!data) throw new Error('Inbox item not found') return data }, }, { name: 'gnubok_create_supplier_invoice_from_inbox', keywords: ['leverantörsfaktura', 'inkorg', 'underlag'], 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)' }, exchange_rate_override: { type: 'number', description: 'SEK per 1 unit of invoice currency; skips the Riksbanken lookup.' }, line_overrides: { type: 'array', description: 'Per-line overrides (1-based line_number): account_number wins over accountSuggestion and supplier default; dimensions tags that line; apply_slp books särskild löneskatt on a 741x pension 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")' }, apply_slp: { type: 'boolean', description: 'Book särskild löneskatt (24.26%, 7533 D / 2514 K) for this line at commit. Only valid when the line resolves to a 741x pension-premium account (tjänstepension); any other account is rejected at staging.', }, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 > VAT number > name 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_vat_number' | 'lookup_name' | 'unresolved' = supplierIdOverride ? 'override' : inbox.matched_supplier_id ? 'matched' : 'unresolved' const supplierIdentity = supplierIdentityFrom(supplierExt) if (!supplierId) { const match = await matchSupplierByIdentity(supabase, companyId, supplierIdentity) if (match) { supplierId = match.supplierId supplierResolution = match.matchedOn === 'org_number' ? 'lookup_org_number' : match.matchedOn === 'vat_number' ? 'lookup_vat_number' : '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 = supplierIdentity.name const extractedOrg = supplierIdentity.orgNumber const CANDIDATE_POOL_CAP = 500 const { data: companySuppliers } = await supabase .from('suppliers') .select('id, name, org_number, vat_number') .eq('company_id', companyId) .limit(CANDIDATE_POOL_CAP) const candidates = findSupplierCandidates( (companySuppliers ?? []) as SupplierRow[], extractedName, extractedOrg, supplierIdentity.vatNumber, ) 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, extracted_vat_number: supplierIdentity.vatNumber, }, 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: a non-SEK invoice needs a rate before approve can post it (the // executor refuses with SI_FX_RATE_MISSING otherwise). Resolved through // the same resolver the commit path uses, WITH the supabase client: the // shared exchange_rates cache is consulted and warmed, and Riksbanken's // rate limiter (429, no Retry-After header) falls back to the most // recent cached observation instead of surfacing as lookup_failed. The // old call omitted the client, so five of eight USD invoices in one // batch staged with exchange_rate: null for no reason but request // ordering, and none of them could be approved (feedback seq 299742). // A caller-supplied exchange_rate_override is trusted verbatim, same as // the v1 route and the web form. let exchangeRate: number | null = null let exchangeRateSource: 'riksbanken' | 'supplied' | 'not_applicable' | 'lookup_failed' = currency === 'SEK' ? 'not_applicable' : 'lookup_failed' const rateOverride = args.exchange_rate_override if (rateOverride !== undefined && rateOverride !== null) { if (typeof rateOverride !== 'number' || !Number.isFinite(rateOverride) || rateOverride <= 0) { throw new Error(`exchange_rate_override must be a positive number (SEK per 1 ${currency}); got ${JSON.stringify(rateOverride)}`) } if (currency === 'SEK') { throw new Error('exchange_rate_override only applies to a non-SEK invoice') } } if (currency !== 'SEK' && invoiceDate) { const fx = await resolveSupplierInvoiceExchangeRate(supabase, { currency, invoiceDate, suppliedRate: typeof rateOverride === 'number' ? rateOverride : null, }) if (fx.ok && fx.rate.exchangeRate !== null) { exchangeRate = fx.rate.exchangeRate exchangeRateSource = fx.rate.source === 'supplied' ? 'supplied' : 'riksbanken' } else if (typeof rateOverride === 'number') { throw new Error(`exchange_rate_override ${rateOverride} was refused as implausible; check the rate on the invoice`) } } // 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; apply_slp?: boolean; dimensions?: unknown }> | undefined) ?? [] const lineOverrideMap = new Map( rawLineOverrides.filter((o) => o.account_number).map((o) => [o.line_number, o.account_number as string]), ) const lineSlpMap = new Map( rawLineOverrides.filter((o) => o.apply_slp !== undefined).map((o) => [o.line_number, o.apply_slp === true]), ) 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 const accountNumber = lineOverrideMap.get(lineNumber) ?? (li.accountSuggestion as string | null) ?? supplierDefaultExpenseAccount ?? '4000' // Särskild löneskatt: same guard the create routes and the executor // enforce (SI_CREATE_SLP_INVALID_ACCOUNT). Reject at staging time on // the RESOLVED account so the agent learns immediately, instead of a // human approving an operation the executor is guaranteed to refuse. const applySlp = lineSlpMap.get(lineNumber) === true if (applySlp && !isSlpPensionAccount(accountNumber)) { const slpEntry = getErrorEntry('SI_CREATE_SLP_INVALID_ACCOUNT') const slpErr = new Error( `line_overrides[line_number=${lineNumber}]: apply_slp on account ${accountNumber}. ` + `${slpEntry?.message_sv ?? 'Särskild löneskatt kan bara läggas till på rader med pensionskonto 7410-7419.'} / ` + `${slpEntry?.message_en ?? 'Särskild löneskatt can only be added on lines booked to a pension account 7410-7419.'}`, ) ;(slpErr as Error & { code?: string }).code = 'SI_CREATE_SLP_INVALID_ACCOUNT' throw slpErr } 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: accountNumber, vat_rate: vatRate, vat_amount: vatAmount, ...(applySlp ? { apply_slp: true } : {}), ...(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: supplierIdentity.name, extracted_org_number: supplierIdentity.orgNumber, extracted_vat_number: supplierIdentity.vatNumber, supplier_invoice_number: supplierInvoiceNumber, invoice_date: invoiceDate, due_date: dueDate, currency, exchange_rate: exchangeRate, exchange_rate_source: exchangeRateSource, // Without a rate the staged op cannot be approved (SI_FX_RATE_MISSING), // so say what unblocks it here rather than at commit time. ...(exchangeRateSource === 'lookup_failed' ? { exchange_rate_hint: `No ${currency}/SEK rate for ${invoiceDate ?? 'the invoice date'} (Riksbanken unreachable or no observation). ` + 'Approval will refuse; re-stage with exchange_rate_override (SEK per 1 ' + currency + ') taken from the invoice.', } : {}), 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', keywords: ['omatchade underlag', 'kvitto', 'kvitton', 'dokument'], 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: ANNOTATIONS_READ_ONLY, 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, document_attachments(file_name)') .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 dbError(inboxError) 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 dbError(txError) 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 // Page coverage of the extraction (set only when the PDF was sliced, // extensions/general/invoice-inbox/lib/upload-and-extract.ts). Lets // the agent tell "this document has no total" from "we read 3 of 38 // pages" instead of reading amount: null as a fact about the file. let pages: { total: number; analyzed: number } | null = null if (extracted) { const pageInfo = extracted.pages as { total?: unknown; analyzed?: unknown } | undefined if (typeof pageInfo?.total === 'number' && typeof pageInfo?.analyzed === 'number') { pages = { total: pageInfo.total, analyzed: pageInfo.analyzed } } 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 } // Original file name, same embed gnubok_list_inbox_items uses: the // archive file name was a better date signal than the OCR'd // invoice_date in every case one reporter checked (feedback seq // 265062), and without it the agent must fetch each document just // to learn what it is. const attachment = item.document_attachments as | { file_name?: string | null } | Array<{ file_name?: string | null }> | null | undefined const fileName = (Array.isArray(attachment) ? attachment[0]?.file_name : attachment?.file_name) ?? null return { inbox_item_id: item.id, document_id: item.document_id, file_name: fileName, 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, pages, } }) // 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', keywords: ['dokument', 'underlag', 'kvitto', 'läs underlag'], 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', 'null'] }, size_bytes: { type: ['number', 'null'] }, signed_url: { type: 'string' }, expires_at: { type: 'string' }, }, required: ['document_id', 'file_name', 'signed_url', 'expires_at'], }, annotations: ANNOTATIONS_READ_ONLY, 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 dbError(docError) 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: toSameOriginStorageUrl(signed.signedUrl), expires_at: expiresAt, } }, }, { name: 'gnubok_attach_document_to_transaction', keywords: ['bifoga underlag', 'kvitto', 'koppla underlag'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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', keywords: ['koppla underlag', 'verifikat', 'kvitto'], title: 'Link Document to Voucher', description: 'Stage linking a document to an already-POSTED verifikation (no bank-tx row). For an unbooked handling prefer gnubok_create_voucher with inbox_item_id (BFL 5 kap 6§). Call gnubok_list_verifikat_without_documents for targets.', 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'], examples: [{ document_id: 'd0c1...', journal_entry_id: 'a44e...' }], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, 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, } ) }, }, { name: 'gnubok_link_documents_to_vouchers', keywords: ['koppla underlag', 'verifikat', 'kvitton'], title: 'Bulk-Link Documents to Vouchers', description: 'Bulk receipt migration: stage up to 300 document-to-verifikat links as ONE approval, addressed by voucher_series/voucher_number/fiscal_year (server resolves the UUID). Returns per-row hit/miss, so a wrong fiscal_year shows before approval. Only resolved rows are staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { links: { type: 'array', minItems: 1, maxItems: 300, items: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string', description: 'UUID of the document_attachments row' }, voucher_series: { type: 'string', minLength: 1, maxLength: 10, description: 'Voucher series letter, e.g. "A"' }, voucher_number: { type: 'integer', minimum: 1, description: 'Voucher number within the series and fiscal year' }, fiscal_year: { type: 'integer', minimum: 2000, maximum: 2100, description: 'Calendar year of the fiscal period (matched against fiscal_periods.period_start), e.g. 2025' }, journal_entry_line_id: { type: 'string', description: 'Optional UUID to pin the doc to a specific debit/credit line' }, }, required: ['document_id', 'voucher_series', 'voucher_number', 'fiscal_year'], }, }, idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' }, dry_run: { type: 'boolean', description: 'Preview without staging' }, }, required: ['links'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, // Search-only: a one-off bulk migration tool does not belong in the default // catalog, which every session pays for in context. The everyday // gnubok_link_document_to_voucher stays default; this one is discovered via // gnubok_search_tools when a migration job actually needs it. Keeping it // default pushed the tools/list projection past the 58.5K token ceiling // (payload-size.bench.test.ts), whose own guidance is to prefer opt-in // search over raising the budget. catalogVisibility: 'search', async execute(args, companyId, userId, supabase, actor) { const rawLinks = args.links as Array<{ document_id: string voucher_series: string voucher_number: number fiscal_year: number journal_entry_line_id?: string }> if (!Array.isArray(rawLinks) || rawLinks.length === 0) throw new Error('links is required (non-empty)') if (rawLinks.length > 300) throw new Error('links: max 300 per call') for (const [i, l] of rawLinks.entries()) { if (!l.document_id) throw new Error(`links[${i}]: document_id is required`) if (!l.voucher_series) throw new Error(`links[${i}]: voucher_series is required`) if (!Number.isInteger(l.voucher_number) || l.voucher_number < 1) { throw new Error(`links[${i}]: voucher_number must be a positive integer`) } if (!Number.isInteger(l.fiscal_year)) throw new Error(`links[${i}]: fiscal_year must be an integer`) } // ── Resolve fiscal_year → fiscal_period_id. A "fiscal_year" here means // the calendar year the period STARTS in; broken (non-calendar) // fiscal years or a company with >1 period starting the same year // are surfaced as a per-row miss rather than guessed at. const { data: periods, error: periodsError } = await supabase .from('fiscal_periods') .select('id, period_start, period_end') .eq('company_id', companyId) if (periodsError) throw dbError(periodsError) const periodsByYear = new Map>() for (const p of periods ?? []) { const year = new Date(p.period_start as string).getUTCFullYear() const bucket = periodsByYear.get(year) ?? [] bucket.push(p as { id: string; period_start: string; period_end: string }) periodsByYear.set(year, bucket) } // ── Batch-fetch documents. const documentIds = [...new Set(rawLinks.map((l) => l.document_id))] const { data: docs, error: docsError } = await supabase .from('document_attachments') .select('id, file_name, mime_type, journal_entry_id') .in('id', documentIds) .eq('company_id', companyId) if (docsError) throw dbError(docsError) const docsById = new Map((docs ?? []).map((d) => [d.id as string, d as { id: string; file_name: string; mime_type: string; journal_entry_id: string | null }])) // ── Batch-fetch candidate journal entries: every resolved period id × // every series named in the payload, then match tuples in JS (the // unique key is (company_id, fiscal_period_id, voucher_series, // voucher_number); see migration 20260402100000). const resolvedPeriodIds = [...new Set( rawLinks.flatMap((l) => (periodsByYear.get(l.fiscal_year) ?? []).map((p) => p.id)) )] const seriesList = [...new Set(rawLinks.map((l) => l.voucher_series))] const voucherNumbers = [...new Set(rawLinks.map((l) => l.voucher_number))] type CandidateJe = { id: string; entry_date: string; description: string voucher_series: string | null; voucher_number: number | null; status: string; fiscal_period_id: string } let jesByKey = new Map() if (resolvedPeriodIds.length > 0) { // Bounded by voucher_number (at most 300 distinct per call) and paged // through fetchAllRows. Without both, a migration into a fiscal year // that already holds more than 1000 vouchers in the series would hit // PostgREST's silent 1000-row cap: the missing entries resolve as // voucher_not_found and vanish from the staged batch, so the caller // approves fewer links than requested with nothing explaining why. const jes = await fetchAllRows( ({ from, to }) => supabase .from('journal_entries') .select('id, entry_date, description, voucher_series, voucher_number, status, fiscal_period_id') .eq('company_id', companyId) .in('fiscal_period_id', resolvedPeriodIds) .in('voucher_series', seriesList) .in('voucher_number', voucherNumbers) .order('id', { ascending: true }) .range(from, to), ) jesByKey = new Map(jes.map((je) => [ `${je.fiscal_period_id}::${je.voucher_series}::${je.voucher_number}`, je, ])) } // ── Resolve each row to a hit or a miss. Only hits are carried into the // staged params; misses are returned in the preview so the caller // sees them without having to approve anything first. type RowResult = { document_id: string voucher_series: string voucher_number: number fiscal_year: number status: 'matched' | 'ambiguous_fiscal_year' | 'unknown_fiscal_year' | 'document_not_found' | 'voucher_not_found' | 'already_linked' | 'duplicate_in_batch' document_file_name?: string journal_entry_id?: string voucher_label?: string voucher_date?: string } // Identifying fields only: spreading the raw row would leak // journal_entry_line_id into a response shape that does not declare it. const rowKey = (l: typeof rawLinks[number]) => ({ document_id: l.document_id, voucher_series: l.voucher_series, voucher_number: l.voucher_number, fiscal_year: l.fiscal_year, }) // ── Statuses of the verifikat these documents are ALREADY attached to. // The WORM guard in the executor refuses to move a document off a // POSTED verifikat, and without this lookup such a row previews as // `matched` and is silently skipped after approval: the user approves // 42 and gets 38. Those entries can sit outside the periods fetched // above, so they need their own lookup. const existingJeIds = [...new Set( (docs ?? []) .map((d) => d.journal_entry_id as string | null) .filter((id): id is string => !!id) )] const postedExistingJeIds = new Set() if (existingJeIds.length > 0) { const { data: existingJes, error: existingErr } = await supabase .from('journal_entries') .select('id, status') .eq('company_id', companyId) .in('id', existingJeIds) if (existingErr) throw dbError(existingErr) for (const je of existingJes ?? []) { if ((je as { status: string }).status === 'posted') postedExistingJeIds.add(je.id as string) } } const results: RowResult[] = [] const matchedLinks: Array<{ document_id: string; journal_entry_id: string; journal_entry_line_id: string | null }> = [] // Same document twice in one payload: the executor applies rows in order, // so against a DRAFT verifikat the second row would silently overwrite // the first (the WORM guard only blocks posted targets). Reject the // repeat here instead of staging a batch whose outcome depends on order. const seenDocumentIds = new Set() for (const l of rawLinks) { if (seenDocumentIds.has(l.document_id)) { results.push({ ...rowKey(l), status: 'duplicate_in_batch' }) continue } seenDocumentIds.add(l.document_id) const yearPeriods = periodsByYear.get(l.fiscal_year) ?? [] if (yearPeriods.length === 0) { results.push({ ...rowKey(l), status: 'unknown_fiscal_year' }) continue } if (yearPeriods.length > 1) { results.push({ ...rowKey(l), status: 'ambiguous_fiscal_year' }) continue } const doc = docsById.get(l.document_id) if (!doc) { results.push({ ...rowKey(l), status: 'document_not_found' }) continue } const period = yearPeriods[0]! const je = jesByKey.get(`${period.id}::${l.voucher_series}::${l.voucher_number}`) if (!je) { results.push({ ...rowKey(l), status: 'voucher_not_found', document_file_name: doc.file_name }) continue } const existingJeId = doc.journal_entry_id if (existingJeId && existingJeId !== je.id && postedExistingJeIds.has(existingJeId)) { results.push({ ...rowKey(l), status: 'already_linked', document_file_name: doc.file_name }) continue } results.push({ ...rowKey(l), status: 'matched', document_file_name: doc.file_name, journal_entry_id: je.id, voucher_label: `${je.voucher_series ?? l.voucher_series}${je.voucher_number ?? l.voucher_number}`, voucher_date: je.entry_date, }) matchedLinks.push({ document_id: l.document_id, journal_entry_id: je.id, journal_entry_line_id: l.journal_entry_line_id ?? null, }) } const matchedCount = matchedLinks.length const missedCount = results.length - matchedCount if (matchedCount === 0) { throw new Error( `No links resolved: 0/${rawLinks.length} matched a real document + voucher. ` + `First miss: ${JSON.stringify(results[0])}` ) } return stagePendingOperation( supabase, companyId, userId, 'link_documents_to_vouchers', `Koppla ${matchedCount} bilagor till verifikat${missedCount > 0 ? ` (${missedCount} utan träff)` : ''}`, { links: matchedLinks }, { total: rawLinks.length, matched_count: matchedCount, missed_count: missedCount, results, }, actor, undefined, { idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, dryRun: args.dry_run === true, } ) }, }, // ── Payroll (Lönehantering) ────────────────────────────────── { name: 'gnubok_list_mileage_trips', keywords: ['körjournal', 'milersättning', 'tjänsteresor'], title: 'List Mileage Trips (Körjournal)', catalogVisibility: 'search', description: 'List körjournal trips for the active company. Filter by date range, status (draft = not yet booked, booked) or employee. Use before gnubok_book_mileage_period to see what would be booked.', inputSchema: { type: 'object', additionalProperties: false, properties: { from: { type: 'string', description: 'From date (YYYY-MM-DD)' }, to: { type: 'string', description: 'To date (YYYY-MM-DD)' }, status: { type: 'string', enum: ['draft', 'booked'], description: 'Filter by status' }, employee_id: { type: 'string', description: 'Filter by employee UUID' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { trips: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, total_km: { type: 'number' }, draft_km: { type: 'number' }, }, required: ['trips', 'count', 'total_km', 'draft_km'], }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, _userId, supabase) { const { listTrips } = await import('@/lib/mileage/mileage-service') const status = args.status as 'draft' | 'booked' | undefined const rows = await listTrips(supabase, companyId, { from: (args.from as string) || undefined, to: (args.to as string) || undefined, status: status === 'draft' || status === 'booked' ? status : undefined, employeeId: (args.employee_id as string) || undefined, }) const { roundOre: round2 } = await import('@/lib/money') const trips = rows.map((t) => ({ mileage_trip_id: t.id, trip_date: t.trip_date, vehicle_type: t.vehicle_type, vehicle_registration: t.vehicle_registration, odometer_start: t.odometer_start, odometer_end: t.odometer_end, distance_km: Number(t.distance_km), from_location: t.from_location, to_location: t.to_location, purpose: t.purpose, visited: t.visited, is_round_trip: t.is_round_trip, status: t.status, journal_entry_id: t.journal_entry_id, salary_run_id: t.salary_run_id, })) return { trips, count: trips.length, total_km: round2(trips.reduce((sum, t) => sum + t.distance_km, 0)), draft_km: round2( trips.filter((t) => t.status === 'draft').reduce((sum, t) => sum + t.distance_km, 0) ), } }, }, { name: 'gnubok_log_mileage_trip', keywords: ['körjournal', 'milersättning', 'tjänsteresa'], title: 'Log Mileage Trip (Körjournal)', catalogVisibility: 'search', description: 'Stage a körjournal trip (date, route, km, purpose per Skatteverket requirements). Approve via gnubok_approve_pending_operation. The trip stays a draft until booked via gnubok_book_mileage_period.', inputSchema: { type: 'object', additionalProperties: false, properties: { trip_date: { type: 'string', description: 'Trip date (YYYY-MM-DD)' }, vehicle_type: { type: 'string', enum: ['own_car', 'company_car_fossil', 'company_car_electric'], description: 'Vehicle type; drives the tax-free rate (default own_car, 25 kr/mil)' }, vehicle_registration: { type: 'string', description: 'Registration number (regnr)' }, odometer_start: { type: 'number', description: 'Odometer at start (km)' }, odometer_end: { type: 'number', description: 'Odometer at arrival (km)' }, distance_km: { type: 'number', description: 'Distance in km' }, from_location: { type: 'string', description: 'Start location' }, to_location: { type: 'string', description: 'Destination' }, purpose: { type: 'string', description: 'Business purpose (ärende)' }, visited: { type: 'string', description: 'Who/which company was visited' }, is_round_trip: { type: 'boolean', description: 'Distance covers the return leg too' }, employee_id: { type: 'string', description: 'Employee UUID when the trip belongs to an employee' }, notes: { type: 'string', description: 'Free-text note' }, }, required: ['trip_date', 'distance_km', 'from_location', 'to_location', 'purpose'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const tripDate = args.trip_date as string const distanceKm = args.distance_km as number if (typeof tripDate !== 'string' || !ISO_DATE_RE.test(tripDate)) { throw new Error('trip_date must be YYYY-MM-DD') } if (typeof distanceKm !== 'number' || !(distanceKm > 0)) { throw new Error('distance_km must be a positive number') } const odoStart = args.odometer_start as number | undefined const odoEnd = args.odometer_end as number | undefined if (odoStart != null && odoEnd != null && odoEnd <= odoStart) { throw new Error('odometer_end must be greater than odometer_start') } const vehicleType = (args.vehicle_type as string) || 'own_car' if (vehicleType !== 'own_car' && !(args.vehicle_registration as string | undefined)?.trim()) { throw new Error('vehicle_registration is required for a förmånsbil trip (körjournal must identify the vehicle)') } // Preview the tax-free allowance at the schablon rate; non-fatal if the // payroll config year is missing. let approxAmount: number | undefined try { const { loadPayrollConfig } = await import('@/lib/salary/payroll-config') const { ratePerMil } = await import('@/lib/mileage/mileage-service') const { roundOre } = await import('@/lib/money') const config = await loadPayrollConfig(supabase, Number(tripDate.slice(0, 4))) approxAmount = roundOre((distanceKm / 10) * ratePerMil(config, vehicleType as never)) } catch { approxAmount = undefined } return stagePendingOperation( supabase, companyId, userId, 'log_mileage_trip', `Körjournal: ${args.from_location} till ${args.to_location} ${tripDate} (${distanceKm} km)`, { trip_date: tripDate, vehicle_type: vehicleType, vehicle_registration: args.vehicle_registration ?? null, odometer_start: odoStart ?? null, odometer_end: odoEnd ?? null, distance_km: distanceKm, from_location: args.from_location, to_location: args.to_location, purpose: args.purpose, visited: args.visited ?? null, is_round_trip: args.is_round_trip === true, employee_id: args.employee_id ?? null, notes: args.notes ?? null, }, { trip_date: tripDate, route: `${args.from_location} → ${args.to_location}`, distance_km: distanceKm, purpose: args.purpose, vehicle_type: vehicleType, ...(approxAmount != null ? { tax_free_allowance_sek: approxAmount } : {}), }, actor, { description: 'Once approved, the trip is a draft in the körjournal. Book the period via gnubok_book_mileage_period.', tool: 'gnubok_book_mileage_period', }, ) }, }, { name: 'gnubok_book_mileage_period', keywords: ['körjournal', 'milersättning', 'bokför resor'], title: 'Book Mileage Period (Milersättning)', catalogVisibility: 'search', description: 'Stage booking of all draft körjournal trips in a date range as one milersättning verifikat: debit 7331 at the tax-free schablon rate, credit 2820/2893/1930. Approve via gnubok_approve_pending_operation. Call gnubok_list_mileage_trips first.', inputSchema: { type: 'object', additionalProperties: false, properties: { from: { type: 'string', description: 'Period start (YYYY-MM-DD)' }, to: { type: 'string', description: 'Period end (YYYY-MM-DD)' }, entry_date: { type: 'string', description: 'Verifikat date (YYYY-MM-DD); must be in an open period' }, counter_account: { type: 'string', enum: ['2820', '2893', '1930'], description: 'Credit side: 2820 skuld till anställda (default), 2893 avräkning aktieägare, 1930 when already paid out from bank' }, employee_id: { type: 'string', description: 'Only book trips for this employee UUID' }, }, required: ['from', 'to', 'entry_date'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const from = args.from as string const to = args.to as string const entryDate = args.entry_date as string for (const [label, value] of [['from', from], ['to', to], ['entry_date', entryDate]] as const) { if (typeof value !== 'string' || !ISO_DATE_RE.test(value)) { throw new Error(`${label} must be YYYY-MM-DD`) } } if (from > to) throw new Error('from must be <= to') if (from.slice(0, 4) !== to.slice(0, 4)) { throw new Error('Schablon rates are per calendar year: book one year at a time') } const counterAccount = (args.counter_account as string) || '2820' // Read-only preflight: aggregate the draft trips so the approver sees // exactly what would be booked. The commit path re-reads atomically. const { listTrips, summarizeTrips } = await import('@/lib/mileage/mileage-service') const { loadPayrollConfig } = await import('@/lib/salary/payroll-config') const { roundOre } = await import('@/lib/money') const trips = await listTrips(supabase, companyId, { from, to, status: 'draft', employeeId: (args.employee_id as string) || undefined, }) if (trips.length === 0) { throw new Error('No unbooked trips in the selected period. Log trips first via gnubok_log_mileage_trip.') } if (new Set(trips.map((t) => t.employee_id ?? 'unassigned')).size > 1) { throw new Error('The period spans several employees. Book per employee by passing employee_id (BFL motpart traceability).') } const config = await loadPayrollConfig(supabase, Number(to.slice(0, 4))) const summaries = summarizeTrips(trips, config) const totalAmount = roundOre(summaries.reduce((sum, s) => sum + s.amount, 0)) return stagePendingOperation( supabase, companyId, userId, 'book_mileage_period', `Bokför milersättning ${from} till ${to}: ${totalAmount} kr (${trips.length} resor)`, { from, to, entry_date: entryDate, counter_account: counterAccount, // Freeze the previewed trip set: the commit fails if the drafts in // range change between staging and approval. trip_ids: trips.map((t) => t.id), ...(args.employee_id ? { employee_id: args.employee_id } : {}), }, { trip_count: trips.length, total_amount: totalAmount, debit_account: '7331', credit_account: counterAccount, summaries: summaries.map((s) => ({ vehicle_type: s.vehicle_type, total_mil: s.total_mil, rate_per_mil: s.rate_per_mil, amount: s.amount, })), }, actor, undefined, { dateForPeriodCheck: entryDate }, ) }, }, { name: 'gnubok_list_employees', keywords: ['anställd', 'anställda', 'personal'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(error) // 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', keywords: ['lön', 'lönekörning', 'löner'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['lönejournal', 'lön', 'bokföringsunderlag lön'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['lön', 'lönekörning', 'ny lönekörning', 'löner'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['lön', 'lönekörning', 'beräkna lön'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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', keywords: ['lön', 'lönekörning', 'bokför lön'], 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: ANNOTATIONS_STAGED_WRITE, 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 dbError(error) 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 dbError(rosterError) 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', keywords: ['arbetsgivardeklaration', 'lön', 'arbetsgivaravgift'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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', keywords: ['momsdeklaration', 'moms', 'granska momsdeklaration'], 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: ANNOTATIONS_READ_ONLY_OPEN_WORLD, 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), undefined, declaration.rcBasisByRate, ) const completenessOk = !isFilingBlocked(completenessChecks) try { const { redovisare, redovisningsperiod, momsuppgift } = await buildMomsuppgift(supabase, companyId, { periodType, year, period }) const res = await skvRequest( supabase, userId, companyId, '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', keywords: ['momsdeklaration', 'moms', 'lämna in moms', 'skatteverket'], 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: ANNOTATIONS_WRITE_OPEN_WORLD, 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, companyId, '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', keywords: ['momsdeklaration', 'moms'], 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: ANNOTATIONS_READ_ONLY_OPEN_WORLD, 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, companyId, '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, companyId, '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', keywords: ['arbetsgivardeklaration', 'lämna in agi'], 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: ANNOTATIONS_WRITE_OPEN_WORLD, 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', catalogVisibility: 'search', keywords: ['arbetsgivardeklaration'], 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: ANNOTATIONS_READ_ONLY_OPEN_WORLD, 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}), // falling back to the receipt on agi_declarations once the kvittens // reconciliation has deleted that cache (same read as GET /agi/status). const { data: localRow } = await supabase .from('extension_data') .select('value') .eq('company_id', companyId) .eq('extension_id', 'skatteverket') .eq('key', `agi_submission_${period}`) .maybeSingle() const periodRecord: AgiSubmissionState | null = await readAgiSubmissionStatus( supabase, companyId, period, localRow?.value ?? 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, companyId }, 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', catalogVisibility: 'search', keywords: ['anställd', 'personal'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(error) 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', catalogVisibility: 'search', keywords: ['lönebesked', 'lönespecifikation', 'lönespec', 'lön'], 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', 'null'], 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: ANNOTATIONS_READ_ONLY, 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 dbError(error) 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', catalogVisibility: 'search', keywords: ['frånvaro', 'sjukfrånvaro', 'semester', 'vab'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['lönebesked', 'lönespecifikation', 'lönerad'], 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. NOTE: recalc rebuilds base salary lines: use gnubok_set_run_salary for this month\'s pay.', 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: ANNOTATIONS_STAGED_WRITE, 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_set_run_salary', keywords: ['lön', 'lönekörning', 'månadslön', 'ändra lön'], title: 'Set This Month\'s Salary', description: 'Stage this run\'s base salary for one employee in a DRAFT salary run (per-run value; the employee\'s fixed salary is untouched). For variable pay, e.g. owner salary; 0 = nollkörning. Commit via gnubok_approve_pending_operation, then gnubok_calculate_salary_run.', // Default catalog: gnubok_call_tool only bridges READ tools, so a // search-only WRITE is uncallable on Claude.ai (the update_customer // lesson, #1876/#1986), and update_payslip_line's description plus the // payroll_month loadout point agents here. Budget accounted for in // payload-size.bench.test.ts's ledger. inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run (must be draft)' }, employee_id: { type: 'string', description: 'UUID of the employee on the run' }, monthly_salary: { type: 'number', description: 'This month\'s gross base salary (SEK, 0 to 10 000 000; 0 books a nollkörning). Monthly employees only: hourly gross derives from hours worked.' }, }, required: ['salary_run_id', 'employee_id', 'monthly_salary'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const { salary_run_id, employee_id, monthly_salary } = args as { salary_run_id: string; employee_id: string; monthly_salary: number } if (!salary_run_id || !employee_id) { throw new Error('salary_run_id and employee_id are required') } if (typeof monthly_salary !== 'number' || !Number.isFinite(monthly_salary) || monthly_salary < 0) { throw new Error('monthly_salary must be a number >= 0') } // Preflight via the shared service in dry-run: verifies draft status and // that the employee is on the run, and yields old/new salary for the // preview. No writes here: the commit path re-runs the service for real. const { setRunEmployeeSalary } = await import('@/lib/salary/run-employees') const preflight = await setRunEmployeeSalary(supabase, { companyId, salaryRunId: salary_run_id, employeeId: employee_id, monthlySalary: monthly_salary, dryRun: true, }) if (!preflight.ok) { throw new Error(`Cannot set run salary: ${preflight.code}`) } const [{ data: run }, { data: emp }] = await Promise.all([ supabase .from('salary_runs') .select('payment_date, period_year, period_month') .eq('id', salary_run_id) .eq('company_id', companyId) .maybeSingle(), 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, 'set_run_salary', `Sätt månadens lön: ${employeeName} ${preflight.data.previous_monthly_salary} kr → ${preflight.data.monthly_salary} kr`, { salary_run_id, employee_id, monthly_salary: preflight.data.monthly_salary }, { salary_run_id, salary_run_employee_id: preflight.data.salary_run_employee_id, employee_id, employee_name: employeeName, previous_monthly_salary: preflight.data.previous_monthly_salary, new_monthly_salary: preflight.data.monthly_salary, salary_type: preflight.data.salary_type, }, actor, { description: 'After approval, recalculate the run so gross, tax and totals reflect this month\'s salary.', tool: 'gnubok_calculate_salary_run', }, run?.payment_date ? { dateForPeriodCheck: run.payment_date as string } : {}, ) }, }, { name: 'gnubok_update_salary_run', title: 'Update Salary Run', description: 'Stage an update of a DRAFT salary run\'s payment_date, voucher_series or notes; frozen past draft (payment_date becomes the booking entry date). Commit via gnubok_approve_pending_operation; recalculate a calculated run after a payment_date change.', // Search-only: tools/list budget is at zero headroom (see the ledger in // payload-size.bench.test.ts) and the payroll_month loadout plus // gnubok_get_salary_run's status field route agents here when needed. catalogVisibility: 'search', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run (must be draft)' }, payment_date: { type: 'string', description: 'New payment date (YYYY-MM-DD); the date the salary verifikat will be booked on. May fall outside the run\'s period month (lön i efterskott): the AGI is declared for the payment month. Supplying it clears the run\'s calculation.' }, voucher_series: { type: 'string', description: 'Voucher series letter (single A-Z)' }, notes: { type: ['string', 'null'], description: 'Free-text note on the run (max 2000 chars); null clears it' }, }, required: ['salary_run_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const { salary_run_id, payment_date, voucher_series, notes } = args as { salary_run_id: string; payment_date?: string; voucher_series?: string; notes?: string | null } if (!salary_run_id) { throw new Error('salary_run_id is required') } const patch: Record = {} if (payment_date !== undefined) patch.payment_date = payment_date if (voucher_series !== undefined) patch.voucher_series = voucher_series if (notes !== undefined) patch.notes = notes if (Object.keys(patch).length === 0) { throw new Error('At least one of payment_date, voucher_series, notes is required') } // Preflight via the shared service in dry-run: verifies draft status // (exactly as the v1 PATCH does) and yields old/new values for the // preview. No writes here: the commit path re-runs the service for real. const { updateDraftSalaryRun } = await import('@/lib/salary/update-run') const preflight = await updateDraftSalaryRun(supabase, { companyId, salaryRunId: salary_run_id, patch: patch as never, dryRun: true, }) if (!preflight.ok) { throw new Error(`Cannot update salary run: ${preflight.code}`) } const d = preflight.data const changeParts: string[] = [] if (d.changes.payment_date !== undefined) { changeParts.push(`utbetalningsdag ${d.previous.payment_date} → ${d.payment_date}`) } if (d.changes.voucher_series !== undefined) { changeParts.push(`serie ${d.previous.voucher_series} → ${d.voucher_series}`) } if (d.changes.notes !== undefined) { changeParts.push('anteckning uppdaterad') } const periodLabel = `${d.period_year}-${String(d.period_month).padStart(2, '0')}` return stagePendingOperation( supabase, companyId, userId, 'update_salary_run', `Uppdatera lönekörning ${periodLabel}: ${changeParts.join(', ')}`, { salary_run_id, patch }, { salary_run_id, period_year: d.period_year, period_month: d.period_month, previous: d.previous, new_payment_date: d.payment_date, new_voucher_series: d.voucher_series, new_notes: d.notes, changes: d.changes, // A payment_date change clears every roster row's // calculation_breakdown at commit (skatteavdrag and the AGI // redovisningsperiod follow the payment month), so booking is // refused until a recalculation has run against the new date. invalidates_calculation: d.changes.payment_date !== undefined, }, actor, d.changes.payment_date !== undefined ? { description: 'Approval clears the run\'s calculation (tax follows the payment month): re-run gnubok_calculate_salary_run before booking.', tool: 'gnubok_calculate_salary_run', } : undefined, // Period gate on the date the verifikat would land on: the new // payment_date when it changes, else the current one. { dateForPeriodCheck: d.payment_date }, ) }, }, { name: 'gnubok_register_absence', keywords: ['frånvaro', 'sjukanmälan', 'semester', 'vab'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['frånvaro', 'ta bort frånvaro'], 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: ANNOTATIONS_DESTRUCTIVE_WRITE, 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', keywords: ['anställd', 'ny anställd', 'personal'], 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', description: 'Requires both dates, else rejected' }, 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['anställd', 'ändra anställd', 'personal'], 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; else requires both dates' }, 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: ANNOTATIONS_STAGED_WRITE, 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 dbError(error) if (!existing) throw new Error('Employee not found') // Preflight the jämkning contract on the merged row so the agent gets // the error at staging time instead of at approval (#2058). The // executor (updateEmployee) runs the same shared validator again. const { touchesJamkning, validateJamkning } = await import('@/lib/salary/jamkning-rules') if (touchesJamkning(patch)) { const [issue] = validateJamkning({ ...(existing as Record), ...patch }) if (issue) throw new Error(`${issue.field}: ${issue.message}`) } 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', keywords: ['anställd', 'ingående saldo', 'semesterdagar'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 dbError(storedErr) 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', catalogVisibility: 'search', keywords: ['semester', 'semestersaldo', 'semesterdagar'], title: 'Get Vacation Balance (Semestersaldo)', description: 'Get one employee\'s open vacation balance: entitled/taken/remaining days, sparade dagar per origin year, forced payouts and estimated semesterlöneskuld in SEK. 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' }, estimated_liability_sek: { type: 'number' }, }, required: ['employee_vacation_balance_id', 'employee_id', 'vacation_year_start', 'entitled_days', 'taken_days', 'remaining_days'], }, annotations: ANNOTATIONS_READ_ONLY, 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 dbError(error) 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 const remaining = roundOre(entitled - taken) // Same simplified BFNAR 2016:10 day valuation the year-close and the v1 // vacation-balance route use, so the three surfaces agree on the SEK // estimate. remaining + sparade dagar, floored at zero: an overdrawn // balance is a receivable, not a negative skuld. const { dayValueSek } = await import('@/lib/salary/semesterberedning') const { data: employee, error: empErr } = await supabase .from('employees') .select('vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week') .eq('id', employeeId) .eq('company_id', companyId) .maybeSingle() if (empErr) throw dbError(empErr) if (!employee) throw new Error(`Employee ${employeeId} not found for this company`) const savedTotal = Object.values((rest.saved_days as Record | null) ?? {}) .reduce((s, d) => s + (Number(d) || 0), 0) const liabilityDays = Math.max(0, remaining + savedTotal) const estimatedLiability = roundOre(liabilityDays * dayValueSek(employee as DayValueEmployee)) return { employee_vacation_balance_id: id, ...rest, remaining_days: remaining, estimated_liability_sek: estimatedLiability, } }, }, { name: 'gnubok_close_vacation_year', keywords: ['semesterår', 'semesterårsskifte', 'semester'], 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: ANNOTATIONS_DESTRUCTIVE_IDEMPOTENT_WRITE, 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', keywords: ['stäng period', 'periodstängning', 'månadsavslut', 'månadsbokslut'], 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: ANNOTATIONS_DESTRUCTIVE_WRITE, 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', keywords: ['lås period', 'låsa bokföringen'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['ångra bokföring', 'avkategorisera'], 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: ANNOTATIONS_STAGED_WRITE, 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_ignore_transaction', title: 'Ignore Transaction (Ignorera)', description: 'Stage ignoring an unbooked bank transaction that is no business event (PSD2 ghost row, duplicate). No verifikat is written, so a locked period does not block it: the answer to TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED. restore: true un-ignores; booked rows are refused.', // Default catalog: gnubok_call_tool only bridges READ tools, so a // search-only WRITE is uncallable on Claude.ai, and the // TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED remediation instructs agents to call // this tool. Budget accounted for in payload-size.bench.test.ts (#1661). inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string', description: 'UUID of the unbooked bank transaction' }, restore: { type: 'boolean', description: 'true = clear the ignore flag instead (default false)' }, dry_run: { type: 'boolean', description: 'Validate and preview without staging.' }, idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' }, }, required: ['transaction_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, async execute(args, companyId, userId, supabase, actor) { const transactionId = String(args.transaction_id ?? '').trim() if (!transactionId) throw new Error('transaction_id is required') const restore = args.restore === true const { data: tx, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, date, is_ignored') .eq('id', transactionId) .eq('company_id', companyId) .maybeSingle() if (txError) throw dbError(txError) if (!tx) throw new Error('Transaction not found') // Preflight through the shared core in dry run (issue #1661): the booked // check covers all three anchors (journal_entry_id, payment allocations, // voucher links) and `changed` tells the preview whether approval would // be a no-op. Nothing is written here; the commit path re-runs the core. const preflight = await setTransactionIgnored(supabase, companyId, transactionId, !restore, { dryRun: true, }) if (!preflight.ok) { const entry = getErrorEntry(preflight.code) throw new Error( `Cannot ${restore ? 'restore' : 'ignore'} transaction: ${preflight.code}. ${entry?.message_en ?? ''}`.trim() ) } const label = tx.merchant_name || tx.description || transactionId return stagePendingOperation(supabase, companyId, userId, 'ignore_transaction', restore ? `Återställ ignorerad transaktion: ${label}` : `Ignorera transaktion: ${label}`, { transaction_id: transactionId, ignored: !restore }, { transaction_id: transactionId, transaction_description: label, amount: tx.amount, currency: tx.currency, date: tx.date, currently_ignored: Boolean(tx.is_ignored), will_be_ignored: !restore, already_in_state: !preflight.changed, // No verifikat is written, so period_status is informational only: // a locked or closed period does not block this operation. writes_verifikat: false, period_lock_applies: false, }, actor, restore ? { description: 'After approval the row is back in the "to book" list: book it via gnubok_categorize_transaction.', tool: 'gnubok_categorize_transaction', args: { transaction_id: transactionId }, } : { description: 'After approval the row leaves the "to book" list. Continue with gnubok_list_uncategorized_transactions.', tool: 'gnubok_list_uncategorized_transactions', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, dateForPeriodCheck: tx.date, } ) }, }, { name: 'gnubok_export_sie', keywords: ['sie', 'sie-fil', 'exportera bokföring'], 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', 'null'] }, org_number: { type: ['string', 'null'] }, generated_at: { type: 'string' }, }, }, annotations: ANNOTATIONS_READ_ONLY, 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', keywords: ['rotavdrag', 'rutavdrag', 'husarbete', 'utbetalningsfil'], 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', keywords: ['rotavdrag', 'rutavdrag', 'beslutsfil', 'skatteverket'], 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', keywords: ['revision', 'revisor', 'bokslutsunderlag'], 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.", inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'Fiscal period UUID' }, include_documents: { type: 'boolean', description: 'Include receipt/document binaries (default true)' }, estimate_only: { type: 'boolean', description: 'Size estimate only, no zip (default false)' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { download_url: { type: ['string', 'null'], description: 'Signed download URL, valid 1 hour, on the app origin (direct Storage URL only when NEXT_PUBLIC_APP_URL is unset); 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: toSameOriginStorageUrl(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', keywords: ['bokslut', 'årsbokslut', 'årsavslut', 'redo för bokslut'], title: 'Year-End Readiness Check', // Budget: 280 chars (output-schema.test.ts). Spend it on the blockers an // agent can act on BEFORE calling, in likelihood order. The four // period-state kinds (period_not_found / _not_ended / _already_closed / // closing_entry_exists) collapse into "period-state": nothing to pre-check // there, the period either is closable or is not. Open items in foreign // currency are warnings, never blockers, because executeYearEndClosing // revalues them in step 2 (lib/core/bookkeeping/year-end-service.ts). description: 'Year-end check. Blockers: kontantmetod_cutoff_required, unbooked_transactions (most common), draft_entries, unexplained_voucher_gap, sequence_mismatch, trial_balance_unbalanced, opening_balance_continuity, next_period_ib_posted, period-state. FX = warning, never blocker.', 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: ANNOTATIONS_READ_ONLY, 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 the lib's blockers into structured entries so the agent (and // any dashboard) can render and act on each one independently. Routing // keys off the stable YearEndBlockerCode via YEAR_END_BLOCKER_KIND, so a // reworded Swedish message no longer silently reclassifies as 'other'. // The `kind` strings are this tool's public contract: never rename one. // A blocker with no mapped code falls back to the wording heuristic // (which also catches legacy English messages), then to 'other'. const blockers = validation.blockers.map(({ code, message }) => ({ kind: YEAR_END_BLOCKER_KIND[code] ?? classifyYearEndBlockerMessage(message), 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_post_kontantmetod_cutoff', keywords: ['kontantmetoden', 'bokslutsmetod', 'brytdag'], title: 'Post Cash-Method Year-End Cut-Off', description: 'Stage the exact year-end receivable/payable cut-off and next-period reversals required for kontantmetoden. Review all proposed lines, then approve with confirmed=true.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the cash-method fiscal period being closed', }, idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries of the same preview and staged operation', }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_DESTRUCTIVE_WRITE, // Specialized cash-method year-end step. The year-end readiness blocker // and year-end skill name it exactly, while search-only visibility avoids // charging every MCP session for a schema most companies never need. catalogVisibility: 'search', 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 }, { data: settings }] = await Promise.all([ supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .maybeSingle(), supabase .from('company_settings') .select('accounting_method, entity_type') .eq('company_id', companyId) .maybeSingle(), ]) if (!period) throw new Error('Fiscal period not found') if (period.is_closed || period.locked_at) { throw new Error('Räkenskapsperioden är stängd eller låst') } if (period.period_end >= getSwedishLocalDate()) { throw new Error('Kontantmetodens bokslutsavgränsning kan bokföras först efter periodens slut') } if (settings?.accounting_method !== 'cash') { throw new Error('Företaget använder inte kontantmetoden') } const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId) if (!nextPeriod) { throw new Error( 'Kontantmetodens bokslutsavgränsning kräver att nästa räkenskapsår är upplagt: vändningarna bokas första dagen på det nya året.', ) } if (nextPeriod.is_closed || nextPeriod.locked_at) { throw new Error( 'Nästa räkenskapsår är stängt eller låst: vändningarna kan inte bokföras. Lås upp perioden och försök igen.', ) } const assessment = await assessKontantmetodCutoff( supabase, companyId, period, nextPeriod.id, (settings.entity_type ?? 'aktiebolag') as EntityType, ) if (assessment.collection.unknownVatTreatment.length > 0) { throw new Error( `${assessment.collection.unknownVatTreatment.length} fakturor saknar momsinställning: ` + `${assessment.collection.unknownVatTreatment.slice(0, 10).join(', ')}. Komplettera dem och försök igen.`, ) } if (assessment.collection.strayVatOnZeroRate.length > 0) { throw new Error( `${assessment.collection.strayVatOnZeroRate.length} fakturor har moms trots en momsfri momsinställning: ` + `${assessment.collection.strayVatOnZeroRate.slice(0, 10).join(', ')}. Rätta dem och försök igen.`, ) } if ( assessment.lines.receivableLines.length === 0 && assessment.lines.payableLines.length === 0 ) { throw new Error('Inga obetalda kund- eller leverantörsfakturor finns vid periodens slut') } if ( assessment.postings.complete || hasIncompleteKontantmetodCutoffPair(assessment.postings, assessment.lines) ) { throw new Error( 'Kontantmetodens bokslutsavgränsning är redan bokförd eller delvis bokförd för perioden. Kontrollera verifikaten innan du försöker igen.', ) } const reversalDate = nextDay(period.period_end) const entityType = (settings.entity_type ?? 'aktiebolag') as EntityType const entries = [ ...(assessment.lines.receivableLines.length > 0 && !assessment.postings.receivableEntryId && !assessment.postings.receivableReversalId ? [ { kind: 'receivable_cutoff', fiscal_period_id: fiscalPeriodId, entry_date: period.period_end, description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivable, total: assessment.lines.receivableTotal, lines: assessment.lines.receivableLines, }, { kind: 'receivable_reversal', fiscal_period_id: nextPeriod.id, entry_date: reversalDate, description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.receivableReversal, total: assessment.lines.receivableTotal, lines: reverseLines(assessment.lines.receivableLines), }, ] : []), ...(assessment.lines.payableLines.length > 0 && !assessment.postings.payableEntryId && !assessment.postings.payableReversalId ? [ { kind: 'payable_cutoff', fiscal_period_id: fiscalPeriodId, entry_date: period.period_end, description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payable, total: assessment.lines.payableTotal, lines: assessment.lines.payableLines, }, { kind: 'payable_reversal', fiscal_period_id: nextPeriod.id, entry_date: reversalDate, description: KONTANTMETOD_CUTOFF_DESCRIPTIONS.payableReversal, total: assessment.lines.payableTotal, lines: reverseLines(assessment.lines.payableLines), }, ] : []), ] return stagePendingOperation( supabase, companyId, userId, 'post_kontantmetod_cutoff', `Kontantmetodens bokslutsavgränsning: ${period.name}`, { fiscal_period_id: fiscalPeriodId, next_fiscal_period_id: nextPeriod.id, period_end: period.period_end, entity_type: entityType, preview_fingerprint: cutoffPreviewFingerprint({ collection: assessment.collection, lines: assessment.lines, entityType, periodEnd: period.period_end, }), }, { fiscal_period_id: fiscalPeriodId, next_fiscal_period_id: nextPeriod.id, receivable_count: assessment.collection.receivables.length, payable_count: assessment.collection.payables.length, entries, }, actor, { description: 'Re-run year-end readiness after the cut-off and all reversals are posted.', tool: 'gnubok_year_end_readiness', args: { fiscal_period_id: fiscalPeriodId }, }, { idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, dateForPeriodCheck: period.period_end, }, ) }, }, { name: 'gnubok_run_year_end', keywords: ['bokslut', 'årsbokslut', 'årsavslut', 'stäng året'], 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: ANNOTATIONS_DESTRUCTIVE_WRITE, 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', keywords: ['ingående balans', 'öppningsbalans', 'ingående saldon'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['valutaomvärdering', 'valutakurs', 'kursvinst'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['nummerlucka', 'verifikationsnummer', 'verifikat', 'luckor'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['verifikat', 'anteckning', 'notering'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 dbError(fetchErr) 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', keywords: ['nummerlucka', 'verifikationsnummer', 'förklara lucka'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['leverantörsfaktura', 'attestera', 'godkänn faktura'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['leverantörsfaktura', 'kreditfaktura', 'kreditera'], 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: ANNOTATIONS_DESTRUCTIVE_WRITE, 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', keywords: ['proforma', 'offert', 'quote', 'kundfaktura', 'omvandla'], title: 'Convert Proforma or Quote to Invoice', description: 'Stage conversion of a proforma or quote (offert) to a real invoice (F-number, items copied). Proforma is cancelled; the quote stays as accepted.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'Proforma or quote UUID' } }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, 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, invoice_number, document_type, status, quote_status, total, currency, customer:customers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw registryError('INVOICE_NOT_FOUND') const isQuote = inv.document_type === 'quote' // Same pre-checks as convertToInvoice (the commit path), so the agent // gets the registry code at staging time instead of a failed approval. if (inv.document_type !== 'proforma' && !isQuote) throw registryError('INVOICE_CONVERT_NOT_CONVERTIBLE') if (inv.status === 'cancelled') throw registryError('INVOICE_CONVERT_SOURCE_CANCELLED') if (isQuote) { if (inv.quote_status === 'declined') throw registryError('INVOICE_CONVERT_QUOTE_DECLINED') const { data: converted, error: convertedError } = await supabase .from('invoices') .select('id') .eq('company_id', companyId) .eq('converted_from_id', id) .neq('status', 'cancelled') .limit(1) .maybeSingle() if (convertedError) throw dbError(convertedError) if (converted) throw registryError('INVOICE_QUOTE_ALREADY_INVOICED') } const customerName = (inv.customer as { name?: string } | null)?.name ?? 'okänd kund' const amount = `${roundOre(Number(inv.total))} ${inv.currency}` return stagePendingOperation(supabase, companyId, userId, 'convert_invoice', isQuote ? `Konvertera offert → faktura: ${inv.invoice_number ?? ''} ${customerName} ${amount}`.replace(/\s+/g, ' ') : `Konvertera proforma → faktura: ${customerName} ${amount}`, { invoice_id: id }, { customer_name: (inv.customer as { name?: string } | null)?.name, source_document_type: inv.document_type, source_invoice_number: inv.invoice_number ?? null, total: inv.total, currency: inv.currency, will: isQuote ? 'allocate F-series number, copy items, mark the quote accepted (the quote stays)' : '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_set_quote_status', keywords: ['offert', 'quote', 'accepterad', 'avböjd', 'godkänn offert'], title: 'Set Quote Status', description: 'Record the customer decision on a quote (offert): open, accepted or declined. Locked once invoiced; expired is derived from valid_until.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'Quote UUID' }, status: { type: 'string', enum: ['open', 'accepted', 'declined'] }, valid_until: { type: 'string', description: 'YYYY-MM-DD; new expiry (reopens an expired quote)' }, }, required: ['invoice_id', 'status'], }, // Kept shallow on purpose: tools/list has a hard token budget // (payload-size.bench.test.ts) and the row shape is documented on // gnubok_get_invoice. outputSchema: { type: 'object' }, annotations: ANNOTATIONS_IDEMPOTENT_WRITE, async execute(args, companyId, userId, supabase) { const id = args.invoice_id as string if (!id) throw codedError('VALIDATION_ERROR', 'invoice_id is required') const nextStatus = args.status as string if (nextStatus !== 'open' && nextStatus !== 'accepted' && nextStatus !== 'declined') { throw codedError('VALIDATION_ERROR', 'status must be open, accepted or declined') } // Mirrors POST /api/invoices/[id]/quote-status: any transition between // the three decisions until the quote has been converted; cancelled // quotes are not decidable; accepting past valid_until is allowed. const { data: quote, error: fetchError } = await supabase .from('invoices') .select('id, document_type, status, quote_status, quote_decided_at') .eq('id', id) .eq('company_id', companyId) .maybeSingle() if (fetchError) throw dbError(fetchError) if (!quote) throw registryError('INVOICE_NOT_FOUND') if (quote.document_type !== 'quote') throw registryError('INVOICE_NOT_A_QUOTE') if (quote.status === 'cancelled') throw registryError('INVOICE_QUOTE_NOT_DECIDABLE') const { data: converted, error: convertedError } = await supabase .from('invoices') .select('id') .eq('company_id', companyId) .eq('converted_from_id', id) .neq('status', 'cancelled') .limit(1) .maybeSingle() if (convertedError) throw dbError(convertedError) if (converted) throw registryError('INVOICE_QUOTE_ALREADY_INVOICED') const nextValidUntil = typeof args.valid_until === 'string' ? args.valid_until : undefined if (nextValidUntil !== undefined && !ISO_DATE_RE.test(nextValidUntil)) { throw codedError('VALIDATION_ERROR', 'valid_until must be YYYY-MM-DD') } // Compare-and-set on the state read above (same as the HTTP routes): a // conversion or cancel that lands in between makes this a 0-row update // instead of overwriting newer state. const { data: updated, error: updateError } = await supabase .from('invoices') .update({ quote_status: nextStatus, // Re-sending the same decision keeps its original timestamp // (idempotentHint on this tool is honest). quote_decided_at: nextStatus === 'open' ? null : nextStatus === quote.quote_status ? (quote.quote_decided_at ?? new Date().toISOString()) : new Date().toISOString(), valid_until: nextValidUntil, updated_at: new Date().toISOString(), }) .eq('id', id) .eq('company_id', companyId) .eq('quote_status', quote.quote_status) .neq('status', 'cancelled') .select('id, invoice_number, document_type, status, quote_status, quote_decided_at, valid_until') .maybeSingle() if (updateError) { // trg_invoices_quote_decision_guard: a conversion landed in between. if (updateError.message?.includes('INVOICE_QUOTE_ALREADY_INVOICED')) { throw registryError('INVOICE_QUOTE_ALREADY_INVOICED') } throw dbError(updateError) } if (!updated) throw registryError('INVOICE_QUOTE_CHANGED_CONCURRENTLY') return { invoice_id: updated.id, invoice_number: updated.invoice_number ?? null, document_type: updated.document_type, status: updated.status, quote_status: updated.quote_status, effective_quote_status: effectiveQuoteStatus(updated) ?? updated.quote_status, quote_decided_at: updated.quote_decided_at ?? null, valid_until: updated.valid_until ?? null, } }, }, { name: 'gnubok_unlock_period', keywords: ['lås upp period', 'öppna 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['kreditfaktura', 'kreditera', 'kundfaktura'], 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: ANNOTATIONS_DESTRUCTIVE_WRITE, 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', keywords: ['ändra faktura', 'kundfaktura', 'faktura'], title: 'Update Draft Invoice', description: 'Stage an edit to a DRAFT invoice: header fields (incl. default_dimensions) and/or items (FULL REPLACE: read current lines with gnubok_get_invoice first; lines accept article_id). Drafts only, no verifikat, not self-billed, not a credit note; otherwise use gnubok_credit_invoice.', 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' }, invoice_marking: { type: 'string', description: 'Fakturamärkning (buyer marking/PO label), separate from your_reference.' }, 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' }, discount_percent: { type: 'number', description: 'Line discount 0-100 (rabatt); pass back to keep it, totals computed net of it.' }, vat_rate: { type: 'number', description: 'VAT rate 0-100 (optional override)' }, article_id: { type: 'string', description: 'Optional article UUID from gnubok_list_articles. Prefills description, unit, unit_price, revenue account and, only when compatible with the customer VAT rules, vat_rate. Values set on the line win. Pass it back on every line that should keep its article linkage.', }, line_type: { type: 'string', enum: ['product', 'text'], description: 'text = free-text/spacer row: no amounts, never books; the quantity/description/unit/price rules are skipped.', }, revenue_account: { type: ['string', 'null'], description: 'BAS class 1-3 posting-account override; null books by VAT treatment. Pass back to keep a manual override.', }, deduction_type: { type: ['string', 'null'], description: 'rot or rut; pass back or the ROT/RUT-avdrag is removed by the replace.' }, labor_hours: { type: ['number', 'null'] }, work_type: { type: ['string', 'null'], description: 'Skatteverket arbetstypskod for the deduction line.' }, housing_designation: { type: ['string', 'null'], description: 'Fastighetsbeteckning; required on ROT lines.' }, apartment_number: { type: ['string', 'null'] }, brf_org_number: { type: ['string', 'null'] }, accrual_period_start: { type: ['string', 'null'], description: 'YYYY-MM-DD; with accrual_period_end defers the revenue (periodisering). Pass back or the deferral is removed.' }, accrual_period_end: { type: ['string', 'null'] }, accrual_balance_account: { type: ['string', 'null'], description: '29xx interim account; null = default.' }, 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: ['quantity'], }, description: 'FULL REPLACE: every existing line is deleted and this array becomes the new line set. Read the current lines with gnubok_get_invoice first and pass unchanged lines back verbatim (article, ROT/RUT, accrual and account fields survive only if passed back). 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 StagedInvoiceLineInput[] | undefined // Cheap pre-query gates. description/unit/unit_price are checked after // the article prefill (resolveInvoiceLineFromArticle), since a line // carrying article_id legitimately omits all three. 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. ' + 'Read the current lines with gnubok_get_invoice first.', ) } for (const [i, item] of rawItems.entries()) { // Text rows are stored with quantity 0 and legitimately come back // that way from gnubok_get_invoice (web parity: the canonical // CreateInvoiceItemSchema exempts them from the quantity rule). if (item.line_type === 'text') continue if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${i + 1}: quantity must be positive`) } } const headerChanges: Record = {} for (const key of ['notes', 'invoice_date', 'due_date', 'delivery_date', 'your_reference', 'our_reference', 'invoice_marking']) { if (args[key] !== undefined) headerChanges[key] = args[key] } if (rawItems === undefined && args.default_dimensions === undefined && Object.keys(headerChanges).length === 0) { throw new Error('Invalid invoice update: at least one invoice field must be supplied') } // The draft first: its customer (VAT rules for the article prefill) and // its currency (article price prefill) are structural and come from the // stored row, never from the arguments. // deduction_personnummer_encrypted is fetched ONLY as a presence check // for the ROT/RUT staging gate below; the ciphertext is never staged, // previewed, or returned. const { data: invoice, error } = await supabase .from('invoices') .select('id, invoice_number, status, document_type, quote_status, journal_entry_id, is_self_billed, credited_invoice_id, total, currency, customer_id, deduction_personnummer_encrypted, customer:customers(name)') .eq('id', invoiceId) .eq('company_id', companyId) .maybeSingle() if (error) throw dbError(error) 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 currency = ((invoice.currency as string) || 'SEK') as Currency const customerName = (invoice.customer as { name?: string } | null)?.name // Items branch (issue #1642): article prefill + default-set VAT // adoption exactly like gnubok_create_invoice, the permitted-set VAT // gate at staging so the agent sees the error here rather than at // approval, and a snapshot of the lines being replaced so the approver // can see a rebooking (revenue_account / vat_rate) in the preview. let items: ResolvedInvoiceLine[] | undefined let defaultVatRate = 0 let subtotal = 0 let vatAmount = 0 let currentItems: Array> | undefined if (rawItems !== undefined) { // personal_number is fetched ONLY as a presence check for the ROT/RUT // staging gate below (commit falls back to the kundkort personnummer // for individuals); never decrypted, staged, or returned here. const { data: customer, error: custError } = await supabase .from('customers') .select('customer_type, vat_number_validated, country, personal_number') .eq('id', invoice.customer_id) .eq('company_id', companyId) .single() if (custError || !customer) { throw new Error('Customer not found: they may have been deleted. The draft cannot be edited without its customer.') } const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, customer.country) defaultVatRate = vatRules.rate const adoptableVatRates = getArticleVatRateAdoptionSet(customer.customer_type, customer.vat_number_validated, customer.country) const articleIds = Array.from(new Set(rawItems.map((i) => i.article_id).filter((a): a is string => !!a))) const articlesById = new Map() if (articleIds.length > 0) { const { data: articleRows, error: articleError } = await supabase .from('articles') .select('id, name, unit, price_excl_vat, vat_rate, revenue_account, currency, active') .eq('company_id', companyId) .in('id', articleIds) if (articleError) throw new Error(`Failed to load articles: ${articleError.message}`) for (const row of articleRows ?? []) articlesById.set(row.id, row) } items = rawItems.map((item, i) => resolveInvoiceLineFromArticle( item, item.article_id ? articlesById.get(item.article_id) : undefined, currency, adoptableVatRates, i, ), ) // Same PERMITTED-set gate as create (taxed-where-performed supplies // carry Swedish VAT even to a foreign business); the default stays // vatRules.rate, so a Swedish rate only lands here when set on the // line or adopted from an article within the default set. const permittedRates = getPermittedVatRates(customer.customer_type, customer.vat_number_validated, customer.country) const allowedRates = new Set(permittedRates.map((r) => r.rate)) for (const item of items) { // Text rows carry no amounts and never book: exclude them from the // VAT gate and the totals, like commitCreateInvoice's billableItems. if (item.line_type === 'text') continue const itemRate = 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 = computeLineNet(item.quantity, item.unit_price, item.discount_percent) subtotal += lineTotal vatAmount += roundOre(lineTotal * itemRate / 100) } // ROT/RUT staging gate: everything buildInvoiceWriteData would refuse // at commit time that this staged set already determines must fail // HERE, where the agent can fix it, not after approval. (The staging // VAT gate above exists for the same reason.) const deductionLines = items.filter((item) => item.line_type !== 'text' && item.deduction_type) if (deductionLines.length > 0) { const claimErrors = validateDeductionLines( deductionLines.map((item) => ({ unit_price: item.unit_price, quantity: item.quantity, discount_percent: item.discount_percent ?? 0, deduction_type: item.deduction_type ?? null, vat_rate: item.vat_rate ?? vatRules.rate, labor_hours: item.labor_hours ?? null, work_type: item.work_type ?? null, })), ) if (claimErrors.length > 0) { throw new Error(`ROT/RUT: ${claimErrors.join(' ')} Read the current lines with gnubok_get_invoice and pass the deduction fields back.`) } // Commit derives the invoice-level property info from the FIRST // deduction line (commitUpdateInvoice), mirrored here. const firstDeduction = deductionLines[0] const housingProvided = Boolean(firstDeduction.housing_designation?.trim()) || (Boolean(firstDeduction.apartment_number?.trim()) && Boolean(firstDeduction.brf_org_number?.trim())) if (deductionLines.some((item) => item.deduction_type === 'rot') && !housingProvided) { throw new Error( 'ROT lines need housing_designation (fastighetsbeteckning), or apartment_number + brf_org_number, on the first deduction line. ' + 'gnubok_get_invoice returns them; pass them back or the update will fail at approval.', ) } // The stored personnummer exists only as ciphertext and cannot be // supplied through MCP: without one on the invoice or on an // individual's kundkort, approval is guaranteed to fail. const personnummerAvailable = Boolean(invoice.deduction_personnummer_encrypted) || (customer.customer_type === 'individual' && Boolean(customer.personal_number)) if (!personnummerAvailable) { throw new Error( 'ROT/RUT lines need a personnummer, which cannot be passed through MCP. ' + 'Add it on the invoice in the web UI or on the customer card first, then retry.', ) } } // Snapshot of the lines this replace deletes, for the approval // preview only (the executor re-reads at commit time). deduction_type // and the accrual period are shown so the approver can see a ROT/RUT // or periodisering removal; no personnummer column exists here and // the property columns are not needed for the preview. const { data: currentRows, error: currentError } = await supabase .from('invoice_items') .select('line_type, description, quantity, unit, unit_price, discount_percent, line_total, vat_rate, revenue_account, article_id, deduction_type, accrual_period_start, accrual_period_end') .eq('invoice_id', invoice.id) .order('sort_order', { ascending: true }) if (currentError) throw dbError(currentError) currentItems = (currentRows ?? []).map((row: Record) => ({ line_type: row.line_type ?? 'product', description: row.description, quantity: row.quantity, unit: row.unit, unit_price: row.unit_price, discount_percent: row.discount_percent ?? 0, line_total: row.line_total, vat_rate: row.vat_rate, revenue_account: row.revenue_account ?? null, article_id: row.article_id ?? null, deduction_type: row.deduction_type ?? null, accrual_period_start: row.accrual_period_start ?? null, accrual_period_end: row.accrual_period_end ?? null, })) } // Resolve-don't-select (same pass as gnubok_create_invoice): parse the // default bag + each RESOLVED 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, ...(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 }) const changes: Record = { ...headerChanges } 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'}`) } 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, currency, changes: parsed.data.changes, ...(stagedItems ? { items_replace: true, item_count: stagedItems.length, // Effective per-line booking, so the approver sees a revenue // account or VAT rate change instead of only a row count. // deduction_type and the accrual period ride along so a // ROT/RUT or periodisering change is visible too. items: stagedItems.map((item) => ({ line_type: item.line_type ?? 'product', description: item.description, quantity: item.quantity, unit: item.unit, unit_price: item.unit_price, line_total: roundOre(item.quantity * item.unit_price), vat_rate: item.line_type === 'text' ? 0 : (item.vat_rate ?? defaultVatRate), revenue_account: item.revenue_account ?? null, article_id: item.article_id ?? null, deduction_type: item.deduction_type ?? null, accrual_period_start: item.accrual_period_start ?? null, accrual_period_end: item.accrual_period_end ?? null, })), current_items: currentItems ?? [], subtotal: roundOre(subtotal), vat_amount: roundOre(vatAmount), total: roundOre(subtotal + vatAmount), } : {}), ...(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_delete_draft_invoice', title: 'Delete Draft Invoice', description: 'Stage removal of a DRAFT invoice for approval. Drafts only: an unnumbered draft is hard deleted; a numbered draft is makulerad (status cancelled, number kept so the F-series stays gap-free). Sent/paid/credited invoices are refused: use gnubok_credit_invoice instead.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the draft invoice, from gnubok_list_invoices.' }, 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'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_DESTRUCTIVE_WRITE, 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 { data: inv } = await supabase .from('invoices') .select('id, invoice_number, status, total, currency, customer:customers(name)') .eq('id', invoiceId).eq('company_id', companyId).single() if (!inv) throw new Error('Invoice not found') if (inv.status !== 'draft') { throw new Error( `Only draft invoices can be deleted (status: ${inv.status}). ` + 'Issued invoices are immutable per BFL: use gnubok_credit_invoice instead.' ) } // Unnumbered draft = hard delete (no F-series number was consumed, so // no gap arises); numbered draft = makulering (number retained so the // F-series stays gap-free). The commit executor re-validates status // with TOCTOU write guards, so a draft sent between staging and // approval is refused, never cancelled. const willHardDelete = !inv.invoice_number return stagePendingOperation(supabase, companyId, userId, 'delete_draft_invoice', willHardDelete ? 'Ta bort fakturautkast (onumrerat)' : `Makulera fakturautkast ${inv.invoice_number}`, // expected_invoice_number pins the APPROVED outcome: if the draft is // finalized (gains a number) between staging and approval, the // executor refuses instead of silently makulera what the approver saw // as a hard delete. { invoice_id: invoiceId, expected_invoice_number: inv.invoice_number ?? null }, { invoice_id: inv.id, invoice_number: inv.invoice_number ?? null, customer_name: (inv.customer as { name?: string } | null)?.name ?? null, total: inv.total, currency: inv.currency, method: willHardDelete ? 'hard delete: the unnumbered draft and its lines are removed; no F-series number was consumed, so no gap arises' : 'makulering: status flips to cancelled and the invoice number is retained, keeping the F-series gap-free', }, actor, { description: 'After approval the draft is removed (unnumbered) or makulerad (numbered). Both outcomes are permanent.', tool: 'gnubok_list_invoices', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, }, ) }, }, { name: 'gnubok_create_sie_upload', keywords: ['sie', 'sie-fil', 'importera bokföring'], title: 'Create SIE Upload', description: 'The SIE-file intake: on claude.ai/Desktop this renders a DRAG-AND-DROP card that reads exact bytes, preflights and imports: call it as soon as an SIE import is next. Elsewhere: PUT raw bytes (max 50 MB) to upload_url, then pass upload_id + sha256 to preflight/import.', inputSchema: { type: 'object', additionalProperties: false, properties: { filename: { type: 'string', minLength: 1, maxLength: 255, description: 'File name with extension, for example "export.se"', }, }, required: ['filename'], }, 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'], }, _meta: { ui: { resourceUri: 'ui://sie-drop/app.html' } }, annotations: { // readOnlyHint MUST stay true on widget-bearing tools: Claude.ai // accepts always-render widgets only on read-only tools and DROPS a // write-annotated one from the connector entirely (the tool flapped // into the Interactive list and vanished, E2E #9 2026-08-26; every // surviving widget tool was readOnly). Honest too: this only mints a // short-lived upload URL; the actual write is the staged // gnubok_import_sie, same shape as receipt_matcher (read-only tool, // writes via separate approval-gated tools). readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const fileName = args.filename as string const lower = fileName.toLowerCase() if (!lower.endsWith('.se') && !lower.endsWith('.sie') && !lower.endsWith('.si')) { throw codedError('VALIDATION_ERROR', 'filename must end in .se, .sie or .si') } const uploadId = crypto.randomUUID() const reservation = await createPendingDocumentUpload(supabase, companyId, userId, uploadId, fileName) // Served from the app origin: agent sandboxes only reach the MCP host, // not .supabase.co. See storage-proxy.ts. return { upload_id: reservation.uploadId, upload_url: toSameOriginStorageUrl(reservation.signedUrl), expires_at: reservation.expiresAt, } }, }, { name: 'gnubok_sie_preflight', catalogVisibility: 'search', keywords: ['sie', 'sie-fil', 'kontrollera sie'], title: 'SIE Preflight Scan', description: 'Scan a SIE file BEFORE import: parse, validate (balances, IB, encoding), duplicates, orgnr match, suggested mappings. Read-only. Call gnubok_create_sie_upload FIRST: its card/URL carries exact bytes; NEVER retype a large file. Mappings feed gnubok_import_sie.', inputSchema: { type: 'object', additionalProperties: false, properties: { file_content: { type: 'string', description: 'Full SIE file contents as text (small files only)' }, file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded (preserves CP437 åäö)' }, upload_id: { type: 'string', description: 'From gnubok_create_sie_upload after PUTting the bytes; the only safe path for large files' }, sha256: { type: 'string', description: 'Hex sha256 of the raw file bytes; verified on upload_id/base64 paths to prove nothing was truncated' }, filename: { type: 'string' }, }, required: ['filename'], }, outputSchema: { type: 'object', properties: { verdict: { type: 'string', enum: ['ok', 'ok_with_warnings', 'invalid', 'duplicate'] }, file: { type: 'object' }, validation: { type: 'object' }, org_number_match: { type: ['object', 'null'] }, duplicate: { type: ['object', 'null'] }, mappings: { type: 'array', items: { type: 'object' } }, mapping_stats: { type: 'object' }, instructions: { type: 'string' }, }, required: ['verdict', 'file', 'validation', 'org_number_match', 'duplicate', 'mappings', 'mapping_stats', 'instructions'], }, annotations: ANNOTATIONS_READ_ONLY, async execute(args, companyId, userId, supabase) { const content = await resolveSieToolContent(args, companyId, userId) if (!content) { throw codedError( 'VALIDATION_ERROR', 'Provide the SIE file as upload_id (from gnubok_create_sie_upload), file_content_base64, or file_content.' ) } const { parseSIEFile, validateSIEFile } = await import('@/lib/import/sie-parser') const { suggestMappings, getMappingStats, isSystemAccount } = await import('@/lib/import/account-mapper') const { scanSieForCp1252Artifacts, formatSieArtifactWarning } = await import('@/lib/import/sie-artifact-scan') const { generateImportPreview, checkDuplicateImport, checkDuplicatePeriodImport } = await import('@/lib/import/sie-import') const { BAS_REFERENCE } = await import('@/lib/bookkeeping/bas-data') let parsed try { parsed = parseSIEFile(content) } catch (e) { throw codedError( 'VALIDATION_ERROR', `SIE-filen kunde inte tolkas: ${e instanceof Error ? e.message : 'okänt fel'}` ) } // Mojibake tripwire (warn, never block): a host that read CP437 bytes // as UTF-8/Latin-1 mangles åäö in names. Numbers and structure survive, // so the import still balances; the user decides whether names matter. const artifactScan = scanSieForCp1252Artifacts(parsed) const encodingWarnings: string[] = [] if (artifactScan.flagged) { encodingWarnings.push( `${formatSieArtifactWarning(artifactScan)} Tip: re-share the file as file_content_base64 to preserve the original bytes.` ) } const validation = validateSIEFile(parsed) // Wrong-company tripwire: importing another company's bookkeeping is // the worst silent failure this flow can have. Digits-only comparison; // missing on either side reports unverified instead of ok. const { data: companyRow } = await supabase .from('companies') .select('org_number') .eq('id', companyId) .maybeSingle() const companyOrg = ((companyRow as { org_number?: string | null } | null)?.org_number ?? '').replace(/\D/g, '') const fileOrg = (parsed.header.orgNumber ?? '').replace(/\D/g, '') const orgMatch = companyOrg && fileOrg ? { verified: true, match: companyOrg === fileOrg, company_org_number: companyOrg, file_org_number: fileOrg } : { verified: false, match: null, company_org_number: companyOrg || null, file_org_number: fileOrg || null } const duplicateFile = await checkDuplicateImport(supabase, companyId, content) let duplicatePeriod = null if (!duplicateFile && parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) { duplicatePeriod = await checkDuplicatePeriodImport( supabase, companyId, parsed.stats.fiscalYearStart, parsed.stats.fiscalYearEnd ) } const duplicate = duplicateFile ? { kind: 'file', import_id: duplicateFile.id, imported_at: duplicateFile.imported_at } : duplicatePeriod ? { kind: 'period', import_id: duplicatePeriod.id, fiscal_year_start: duplicatePeriod.fiscal_year_start, fiscal_year_end: duplicatePeriod.fiscal_year_end, imported_at: duplicatePeriod.imported_at, } : null const bookkeepingAccounts = parsed.accounts.filter((a) => !isSystemAccount(a.number)) const { data: storedMappings } = await supabase .from('sie_account_mappings') .select('*') .eq('company_id', companyId) const mappings = suggestMappings( bookkeepingAccounts, BAS_REFERENCE, (storedMappings as import('@/lib/import/types').SIEAccountMappingRecord[]) || undefined ) const mappingStats = getMappingStats(mappings) const preview = generateImportPreview(parsed, mappings) const allWarnings = [...validation.warnings, ...encodingWarnings] const verdict = !validation.valid ? 'invalid' : duplicate ? 'duplicate' : allWarnings.length > 0 || orgMatch.match === false ? 'ok_with_warnings' : 'ok' return { verdict, file: { filename: args.filename, company_name: parsed.header.companyName, org_number: parsed.header.orgNumber, sie_type: parsed.header.sieType, source_program: parsed.header.program ?? null, fiscal_year: { start: parsed.stats.fiscalYearStart, end: parsed.stats.fiscalYearEnd }, account_count: bookkeepingAccounts.length, voucher_count: parsed.stats.totalVouchers, transaction_line_count: parsed.stats.totalTransactionLines, opening_balance_total: preview.openingBalanceTotal ?? null, }, validation: { valid: validation.valid, errors: validation.errors, warnings: allWarnings }, org_number_match: orgMatch, duplicate, mappings, mapping_stats: mappingStats, instructions: verdict === 'invalid' ? 'The file has blocking errors: report them to the user and do not import. A fresh export from the source system usually fixes them.' : verdict === 'duplicate' ? 'This file or fiscal year is already imported. Report it; use gnubok_undo_sie_import first if the user wants to replace it.' : orgMatch.match === false ? 'STOP: the file belongs to a different organisation than this company. Confirm with the user before any import.' : 'Summarize the scan for the user (source system, fiscal year, voucher count, balance status, any warnings). On their go-ahead call gnubok_import_sie with this same file and the returned mappings; the import stages for approval.', } }, }, { name: 'gnubok_import_sie', keywords: ['sie', 'sie-fil', 'importera bokföring', 'byta system'], 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. Always staged. Run gnubok_sie_preflight first; large files arrive byte-exact via gnubok_create_sie_upload (card/URL), NEVER retyped inline.', inputSchema: { type: 'object', additionalProperties: false, properties: { file_content: { type: 'string', description: 'Full SIE file contents (small files only)' }, file_content_base64: { type: 'string', description: 'Exact file bytes base64-encoded; alternative to file_content (preserves CP437 åäö)' }, upload_id: { type: 'string', description: 'From gnubok_create_sie_upload after PUTting the bytes; the only safe path for large files' }, sha256: { type: 'string', description: 'Hex sha256 of the raw file bytes; verified on upload_id/base64 paths' }, 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', description: 'Default false (web wizard defaults true)' }, import_transactions: { type: 'boolean' }, voucher_series: { type: 'string', description: 'Override voucher series for imported vouchers' }, opening_balance_series: { type: 'string', description: 'Series for the IB voucher; default avoids series used by the file' }, 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: ['filename', 'mappings'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { // Decoded once here; the staged params carry the decoded text so // commitImportSie re-parses exactly what was previewed. const fileContent = await resolveSieToolContent(args, companyId, userId) const filename = args.filename as string const mappings = args.mappings as unknown[] | undefined if (!fileContent || !filename || !Array.isArray(mappings)) { throw new Error('file content (upload_id, file_content_base64 or 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, // Hosts do not always enforce inputSchema: a non-string must fall // back to the engine default, not crash at commit time. opening_balance_series: typeof args.opening_balance_series === 'string' ? args.opening_balance_series : undefined, // 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', keywords: ['sie', 'ångra 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: ANNOTATIONS_DESTRUCTIVE_WRITE, 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 reasonTooLongError() } // 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', keywords: ['verifikat', 'verifikation', 'manuell bokföring', 'bokföringsorder', 'manuellt verifikat'], title: 'Create Manual Voucher (Verifikation)', description: 'Stage a manual verifikation with balanced lines: capitalization, accruals, FX, rättelser, IB. For a received handling pass inbox_item_id: the document becomes the verifikation (BFL 5 kap 6§); a filename in notes is not underlag. 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: 'Inbox item UUID to book directly. For a received handling this is the BFL 5 kap 6§ path: the document becomes the verifikation. On confirm the item and its document link to the new verifikat. Not in inbox? gnubok_upload_document first. Fails if already booked/converted.' }, 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'], // Balance is the rule agents break: sum(debit) === sum(credit), and the // moms leg is its own line on its own BAS account, never folded in. examples: [ { entry_date: '2026-03-31', description: 'Kontorsmaterial Kjell & Company', lines: [ { account_number: '6110', debit_amount: 400 }, { account_number: '2641', debit_amount: 100 }, { account_number: '1930', credit_amount: 500 }, ], }, ], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, 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. // Shape first: a line with neither amount key is a schema mismatch and // must say so, never coerce to 0/0 and fail the balance check instead. for (const [i, l] of rawLines.entries()) assertVoucherLineShape(l, `lines[${i}]`) 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 a line references an // account the ENGINE could not resolve either (same semantics as // findUnresolvableAccounts): a BAS 2026 number merely absent from the // chart passes, because createDraftEntry seeds it at commit; only // numbers outside both the chart and the BAS catalog, plus rows a user // deliberately deactivated (the backfill never resurrects those), are // rejected here. 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 unseedableAccounts = unknownAccounts.filter((n) => !getBASReference(n)) const seedableAccounts = unknownAccounts.filter((n) => Boolean(getBASReference(n))) const inactiveAccounts = accountNumbers.filter( (n) => accountInfo.has(n) && !accountInfo.get(n)!.active, ) if (unseedableAccounts.length > 0 || inactiveAccounts.length > 0) { const parts: string[] = [] if (unseedableAccounts.length > 0) { parts.push(`saknas i kontoplanen och finns inte i BAS 2026: ${unseedableAccounts.join(', ')}`) } if (inactiveAccounts.length > 0) { parts.push(`inaktiva: ${inactiveAccounts.join(', ')}`) } // Carry the stable code, not just the prose. The message here is the // better one (it names the accounts AND the two tools that fix it), // but a bare Error resolves to UNKNOWN_ERROR, so an agent branching on // `code` sees "unknown" for a failure that has a documented remedy. // ACCOUNTS_NOT_IN_CHART already exists in the registry with a // remediation pointing at Accounted://chart-of-accounts, and // storno-service already throws it; this path just never did. // On production this was 40 of the create_voucher failures in 60 days. throw Object.assign( new Error( `Kan inte skapa verifikation. Konton ${parts.join('; ')}. ` + 'Skapa kontot med gnubok_create_account, aktivera det med gnubok_update_account, eller välj andra konton.' ), { code: ACCOUNTS_NOT_IN_CHART, accountNumbers: [...unseedableAccounts, ...inactiveAccounts], }, ) } const previewLines = lines.map((l) => ({ account_number: l.account_number, // Fallback to the BAS catalog name for a seedable account that is not // in the chart yet, so the approver still reads a named line. account_name: accountInfo.get(l.account_number)?.name ?? getBASReference(l.account_number)?.account_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_journal_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, // BAS accounts not yet in the chart: the engine activates them at // commit. Surfaced so the approver sees the side-effect up front. ...(seedableAccounts.length > 0 ? { will_activate_accounts: seedableAccounts } : {}), // 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), // The document-attach claim keys off inboxDocumentId, not // inboxItemId: an inbox item whose document was never stored (or // was deleted, ON DELETE SET NULL) links but attaches nothing. will: inboxDocumentId ? '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' : inboxItemId ? 'create a posted journal entry with a fresh sequential voucher number and link the inbox item to it (the item has no stored document, so nothing is attached)' : '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, // Advisory only (the approver decides): a verifikat for a received // handling posted without its document fails BFL 5 kap 6§, and the // repair after posting is storno + a consumed voucher number. The // inbox-item variant avoids a dead-end "restage with inbox_item_id" // instruction when inbox_item_id WAS supplied but the item carries // no stored document. IB entries are exempt: a migrated ingående // balans has no kvitto to attach and warning on it would be a false // positive that trains approvers to ignore the warning. ...(inboxDocumentId || isOpeningBalance ? {} : { complianceNote: inboxItemId ? 'No underlag attached (document_attached: false): the inbox item has no stored document, so nothing will be attached. Upload the handling via gnubok_upload_document and restage, or link it to the posted verifikat with gnubok_link_document_to_voucher.' : 'No underlag attached (document_attached: false). For a received handling, BFL 5 kap 6§ requires the document itself to serve as the verifikation: restage with inbox_item_id (upload via gnubok_upload_document first if needed), or link the document to the posted verifikat with gnubok_link_document_to_voucher. A filename in description or notes is not underlag.', }), }, ) }, }, { name: 'gnubok_correct_entry', keywords: ['rättelse', 'rätta verifikat', 'verifikation', 'ändra verifikat'], 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'], }, }, allow_deep_chain: { type: 'boolean', description: 'Override the chain-depth guard (refuses at 3+ rättelse levels: book ONE net-effect correction instead). True only when another layer is intended.', }, }, required: ['entry_id', 'lines'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, async execute(args, companyId, userId, supabase, actor) { const entryRef = args.entry_id as string const rawLines = args.lines as Array> | undefined const allowDeepChain = args.allow_deep_chain === true if (!entryRef || !Array.isArray(rawLines) || rawLines.length < 2) { throw new Error('entry_id and at least two lines are required') } for (const [i, l] of rawLines.entries()) assertVoucherLineShape(l, `lines[${i}]`) 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 correction_of_id: string | null reverses_id: string | null 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, correction_of_id, reverses_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 dbError(origErr, `Database error looking up journal entry ${entryId}`) } 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.` ) } // Chain-depth guard at staging time so the agent reconsiders NOW, not at // approval. The executor (commitCorrectEntry → correctEntry) re-checks. if (!allowDeepChain) { const chain = await correctionChainDepth(supabase, companyId, original) if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) { throw new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher) } } 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, ...(allowDeepChain ? { allow_deep_chain: true } : {}), }, { 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', keywords: ['storno', 'återför', 'makulera', 'verifikat'], 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 the original entry date. 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.' }, allow_deep_chain: { type: 'boolean', description: 'Override the chain-depth guard (refuses at 3+ rättelse levels: book ONE net-effect correction instead). True only when another storno layer is intended.', }, }, required: ['entry_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: ANNOTATIONS_STAGED_WRITE, 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 const allowDeepChain = args.allow_deep_chain === true 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 reasonTooLongError() } 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 correction_of_id: string | null reverses_id: string | null 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, correction_of_id, reverses_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 dbError(origErr, `Database error looking up journal entry ${entryId}`) } 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.` ) } // Chain-depth guard at staging time (mirrors gnubok_correct_entry): a // storno on an entry already deep in a rättelse chain is almost always // an agent reflexively cancelling its own correction. if (!allowDeepChain) { const chain = await correctionChainDepth(supabase, companyId, original) if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) { throw new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher) } } 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, ...(allowDeepChain ? { allow_deep_chain: true } : {}), }, { 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', keywords: ['bokslutsdispositioner', 'periodiseringsfond', 'skatteberäkning'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['periodisering', 'upplupna kostnader', 'förutbetalda intäkter'], 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: ANNOTATIONS_READ_ONLY, 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', catalogVisibility: 'search', keywords: ['periodisering', 'periodiseringar'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['avskrivning', 'avskrivningar', 'inventarier'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['avskrivning', 'avskrivningar', 'bokför avskrivningar'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['årsredovisning', 'förhandsgranska årsredovisning'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['årsredovisning', 'granska årsredovisning'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['årsredovisning', 'versioner'], 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: ANNOTATIONS_READ_ONLY, 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 } }, // Search-only (2026-09-03): versions exist only once an annual report is // rendered for signing or filing, and iXBRL filing is switched off until // the Bolagsverket avtal and certificate exist (same reason its sibling // gnubok_get_arsredovisning_filing_status below is search-only). Demoted // to keep tools/list under the context budget after #2254 added the // proforma fields and #2240 the jamkning field notes (see // payload-size.bench.test.ts). Reachable via gnubok_call_tool. catalogVisibility: 'search', }, { name: 'gnubok_get_arsredovisning_filing_status', keywords: ['årsredovisning', 'bolagsverket', 'inlämning'], 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: ANNOTATIONS_READ_ONLY, // Search-only (2026-09-02): iXBRL filing is switched off until the // Bolagsverket avtal and certificate exist, so nothing polls this status // yet; demoted to make room in tools/list for gnubok_set_quote_status // (see payload-size.bench.test.ts). Reachable via gnubok_call_tool. catalogVisibility: 'search', 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', keywords: ['ne-bilaga', 'enskild firma', 'inkomstdeklaration'], 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: ANNOTATIONS_READ_ONLY, 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', keywords: ['väntande åtgärder', 'att godkänna', 'godkännanden'], title: 'List Pending Operations', description: 'List staged pending_operations. Approve via gnubok_approve_pending_operation, reject via gnubok_reject_pending_operation; without pending_operations:approve use /pending. 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: ANNOTATIONS_READ_ONLY, // 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 return { operations, ...pageTail(operations, totalCount, offset) } }, }, { name: 'gnubok_approve_pending_operation', keywords: ['godkänn', 'bekräfta'], 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 and any preview compliance_warning first. The /pending web UI is 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'], // confirmed is not optional for a high-risk operation: without it the // approval is refused, which reads to an agent as a permissions problem. examples: [{ operation_id: '9a44...' }, { operation_id: '9a44...', confirmed: true }], }, outputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['committed', 'rejected', 'failed'] }, operation_id: { type: 'string' }, data: { type: 'object' }, error: { type: 'string' }, error_code: { type: 'string' }, auto_rejected: { type: 'boolean' }, operation_status: { type: 'string', enum: ['pending', 'committed', 'rejected', 'failed_partial'], description: 'pending = not consumed, re-approvable' }, }, required: ['status', 'operation_id'], }, annotations: ANNOTATIONS_DESTRUCTIVE_WRITE, 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 } : {}), // Only an api_key actor can carry a ceiling; the ternary above // already collapsed everything else to 'user', for which // exceedsUnattendedLimit returns false regardless. ...(actor?.type === 'api_key' ? { unattendedCommitLimit: actor.unattendedCommitLimit ?? null } : {}), }, ...(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.code ? { error_code: result.code } : {}), ...(result.auto_rejected ? { auto_rejected: true } : {}), ...(result.operation_status ? { operation_status: result.operation_status } : {}), } }, }, { name: 'gnubok_reject_pending_operation', keywords: ['avvisa', 'neka'], 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: ANNOTATIONS_STAGED_WRITE, 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', keywords: ['inkorg', 'underlag', 'tolka kvitto'], 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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 → VAT number → name, ILIKE). const matchedSupplierId = await matchSupplierId(supabase, companyId, extracted.supplier) 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', keywords: ['återkommande fakturor', 'stående faktura', 'abonnemang'], title: 'List Recurring Invoice Schedules', description: "List the company's recurring invoice schedules: auto-create customer invoices on day_of_month (clamps to the last day in shorter months) every interval_months months (any 1-12; presets 1/3/6/12) at send_hour, Europe/Stockholm. 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' }, interval_months: { type: 'number', description: 'Months between runs: any integer 1-12; 1 = monthly, 3 = quarterly, 6 = half-yearly, 12 = yearly' }, 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: ANNOTATIONS_READ_ONLY, 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, interval_months, 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 dbError(error) 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, interval_months: row.interval_months, 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', keywords: ['återkommande faktura', 'stående faktura', 'abonnemang'], title: 'Create Recurring Invoice Schedule', description: 'Stage a new recurring invoice schedule: creates a customer invoice on day_of_month (clamps to the last day in shorter months) every interval_months months (default 1) at send_hour, Europe/Stockholm. 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.', }, interval_months: { type: 'integer', minimum: 1, maximum: 12, description: 'Months between invoices: any integer 1-12. Default 1 (monthly); 3 = quarterly, 6 = half-yearly, 12 = yearly.', }, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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', 'interval_months', '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 dbError(error) 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, interval_months: params.interval_months, 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', keywords: ['återkommande faktura', 'stående faktura'], 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.', }, interval_months: { type: 'integer', minimum: 1, maximum: 12, description: 'Months between invoices: any integer 1-12; 1 = monthly, 3 = quarterly, 6 = half-yearly, 12 = yearly. Changing only interval_months leaves next_run_date untouched.', }, 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: ANNOTATIONS_IDEMPOTENT_WRITE, 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', 'interval_months', '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, interval_months, 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 dbError(error) 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 dbError(targetError) 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, interval_months: current.interval_months, 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 ───────────────────────────────────── // Build identifier so MCP clients can tell deploys apart in `initialize` // serverInfo. Resolved once at module load (constant per process, so the // definitions layer stays deterministic); the same identifier /api/health // and the behandlingshistorik report stamp. '1.0.0' is the self-hosted // fallback when no commit SHA is inlined at build. const SERVER_VERSION = currentAppVersion() ?? '1.0.0' const SERVER_INFO_BY_NAMESPACE = { gnubok: { // Stable legacy identity for every existing connection. name: 'gnubok', title: 'Accounted', version: SERVER_VERSION, }, accounted: { name: 'accounted', title: 'Accounted', version: SERVER_VERSION, }, } 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. // "Static" content (tools/list, widget HTML, prompts) is static only within // one deploy: every deploy can add tools and change widgets. The 1-hour hint // this used to carry made Claude.ai serve a pre-deploy catalog for up to an // hour after a release: a freshly shipped tool flapped in and out of the // connector's tool list depending on which fetch hit the client cache // (E2E #7/#8, 2026-08-26). 5 minutes bounds that window to one coffee sip. const CACHE_STATIC = { ttlMs: 300_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' | 'invalid_arguments' | 'unknown_tool' | 'test_key_write_blocked' | 'bridge_refused' | null errorMessage: string | null /** * The specific English diagnostic, passed as `structured.error.message_en`. * Stored only when it differs from errorMessage, so the common case where * message_sv is already the domain message costs nothing. */ errorDetail?: string | null /** * Machine vocabulary for the unmapped failures (#2051): errorCauseTag(err), * i.e. the SQLSTATE or coded-error code, else the error's class name. For * UNKNOWN_ERROR rows message_sv is the constant "Något gick fel. Försök * igen.", so without this the residue cannot be clustered at all. Never the * raw driver message: that can quote row values from a constraint violation * and belongs in the server log, not in event_log. */ errorCause?: string | null requestId: string | number | null userId: string // null/empty while the key's user has no company yet (issue #1814): the // event-log persister requires a company scope, so nothing is emitted. companyId: string | null }): void { if (!payload.companyId) return const companyId = payload.companyId 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, // Only when it adds something. For most domain failures message_sv IS // the specific text and this is null; for the generic registry // defaults it is the difference between a usable log and a shrug. errorDetail: payload.errorDetail && payload.errorDetail !== payload.errorMessage ? payload.errorDetail.slice(0, 500) : null, // Protocol vocabulary only (a SQLSTATE, a code, a class name), capped // hard: anything longer is a message pretending to be a tag. errorCause: payload.errorCause ? payload.errorCause.slice(0, 64) : null, requestId: payload.requestId, userId: payload.userId, 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 | null }): void { if (!payload.companyId) return const companyId = payload.companyId 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, 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 | null }): void { if (!payload.companyId) return const companyId = payload.companyId 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, 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 | null, ): void { if (!sessionId || !companyId) 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 | null }): void { if (!payload.companyId) return const companyId = payload.companyId 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, }, }) .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 | null }): void { if (!payload.companyId) return const companyId = payload.companyId 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, }, }) .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()}"` const unauthorized = () => new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': wwwAuth }, }) // ── Parse JSON-RPC ── // Parsed before auth: with lazy authentication (issue #1814) the method and // tool name decide whether a token is required at all. A body that cannot // be parsed keeps the pre-lazy-auth answer for a tokenless caller (401), so // probing the endpoint without credentials learns nothing new. const token = extractBearerToken(request) // Eager authentication (auth-mode.ts): the URL opted out of lazy auth, so a // tokenless caller is challenged before the body is even looked at. This is // what makes claude.ai's Add-custom-connector probe detect OAuth instead of // "None"; a caller with a token is unaffected. if (!token && isEagerAuthRequested(request)) return unauthorized() let body: JsonRpcRequest try { body = await request.json() } catch { if (!token) return unauthorized() return NextResponse.json( jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'), { status: 400 } ) } // Fire-and-forget notification: no id, no response expected. Answering // 401 here confuses clients, so it is accepted before any auth check. if (body.method === 'notifications/initialized') { return new Response(null, { status: 202 }) } if (body.jsonrpc !== '2.0' || !body.method) { if (!token) return unauthorized() return NextResponse.json( jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'), { status: 400 } ) } // ── Auth ── // Lazy authentication: a client with no token may connect, list the // catalog and call the PUBLIC_TOOLS. Every other request answers 401 + // WWW-Authenticate at the transport level: that challenge is what the // client turns into its Connect prompt (a 200 with isError never would). // The account can be created inside that prompt (app/api/mcp-oauth), so // the first protected tool call is the whole signup trigger. const requestedTool = body.method === 'tools/call' ? toCanonicalToolName(String((body.params as Record | undefined)?.name ?? '')) : null const anonymousAllowed = ANONYMOUS_METHODS.has(body.method) || (requestedTool !== null && isPublicTool(requestedTool)) if (!token && !anonymousAllowed) return unauthorized() const isAnonymous = !token let userId = '' let companyId: string | null = null let keyScopes: ApiKeyScope[] = [] let apiKeyId: string | undefined let apiKeyName: string | undefined let keyMode: ApiKeyMode = 'live' let unattendedCommitLimit: number | null = null if (token) { 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': String(RATE_LIMIT_RETRY_AFTER_SECONDS), }, }) } return unauthorized() } ;({ userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName, mode: keyMode, unattendedCommitLimit, } = authResult) } else { // Anonymous traffic has no key to rate-limit on: per truncated IP instead. // No-op without Upstash (self-hosted), like the OAuth register endpoint. const rl = await checkRateLimit({ prefix: 'mcp:anonymous', identifier: anonymousRateLimitIdentifier(request), ...ANONYMOUS_RATE_LIMIT, }) if (!rl.ok) return rl.response! } 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 = isAnonymous ? { type: 'anonymous', label: 'Not connected', sessionId, client } : { type: 'api_key', id: apiKeyId, label: apiKeyName ?? 'Unnamed API key', unattendedCommitLimit, sessionId, client, } // ── 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.', '', ...(isAnonymous ? [ 'NOT CONNECTED YET. Without an account you can call gnubok_search_tools, gnubok_list_skills and gnubok_load_skill. Every other tool needs the user to connect their Accounted account: calling one returns an authentication challenge that your client shows as a Connect prompt. A user who has no account creates one right there (BankID or e-mail, about a minute), and the call is then retried automatically. To set up bookkeeping for a company that is not in Accounted yet, load the "onboarding" skill (gnubok_load_skill) and follow it: gnubok_create_company is the first protected call and triggers the connect step.', '', ] : []), '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. If your client cannot invoke a tool that is not in tools/list, reach any READ tool through gnubok_call_tool({tool, arguments}); writes must be named directly.', '• 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 ?? 'none yet: this account has no company. Create it with gnubok_create_company (preview first, then confirm=true); the "onboarding" skill walks the whole setup'}); 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.', '• When a tool is missing, a description misled you, a result looks wrong, or something worked unusually well, call gnubok_feedback (context + suggestion, optional tool_name). It is read by the product team and has fixed real bugs; include ids and what you expected. Rate-limited 1/min/key, so batch a session\'s findings into one call.', '', '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: run gnubok_year_end_readiness first. For kontantmetoden, resolve kontantmetod_cutoff_required with the searchable gnubok_post_kontantmetod_cutoff tool. Then gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each write 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 // Not connected yet: the whole default catalog is listed so the agent // can pick the right tool; calling a protected one is what produces // the 401 challenge that starts the connect (and signup) flow. if (isAnonymous) return true 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 outerToolName = typeof rawRequestedToolName === 'string' ? rawRequestedToolName : '' const outerToolArgs = ((params as Record)?.arguments ?? {}) as Record< string, unknown > // gnubok_call_tool bridge. Rewrite {tool, arguments} into a direct call // on the inner tool BEFORE resolution, so the scope check, the // unknown-argument guard, company routing, the test-key write block, the // staging _meta and telemetry below all apply to the real target. A // wrapper that called the inner tool's execute() itself would have // skipped every one of them. const viaBridge = toCanonicalToolName(outerToolName) === 'gnubok_call_tool' const requestedToolName = viaBridge ? typeof outerToolArgs.tool === 'string' ? outerToolArgs.tool : '' : outerToolName const toolName = toCanonicalToolName(requestedToolName) const rawToolArgs = viaBridge ? ((outerToolArgs.arguments ?? {}) as Record) : outerToolArgs const tool = tools.find((t) => t.name === toolName) // The pre-auth gate already refused anonymous calls to anything outside // PUBLIC_TOOLS; re-checked here so the dispatcher never depends on it. // Ordered ahead of the bridge refusal below so an anonymous caller is // turned away before it learns whether a named tool is read-only. // // The bridge cannot widen anonymous reach, and is doubly closed: the // pre-auth gate keys on the OUTER name, and gnubok_call_tool is not in // PUBLIC_TOOLS, so an anonymous bridged call is refused before it gets // here; this line then re-checks the INNER name. Nothing is lost by // that, because all three public tools are in the default catalog and // an anonymous caller never needs the bridge to name them. if (isAnonymous && !isPublicTool(toolName)) return unauthorized() // The bridge reaches reads only. A write must be named directly so the // client sees its own annotations and its staging/approval contract // rather than a generic wrapper's. An unknown-but-named target falls // through to the unknown-tool handler below, which lists what exists. if (viaBridge && (!requestedToolName || (tool && tool.annotations.readOnlyHint !== true))) { const bridgeError = toToolError( codedError( 'VALIDATION_ERROR', requestedToolName ? `${requestedToolName} is not a read-only tool, so gnubok_call_tool will not invoke it. Call ${requestedToolName} directly by name.` : 'gnubok_call_tool requires a "tool" argument naming the read-only tool to invoke.', ), { toolName: 'gnubok_call_tool' }, ) emitToolCallTelemetry({ tool: 'gnubok_call_tool', requiredScope: null, actor, latencyMs: 0, success: false, isError: true, errorCode: bridgeError.error.code, errorKind: 'bridge_refused', errorMessage: bridgeError.error.message_sv, errorDetail: bridgeError.error.message_en, requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpc(id ?? null, decorate({ content: [ { type: 'text', text: JSON.stringify(projectMcpPayload(bridgeError, toolNamespace), null, 2), }, ], isError: true, })) ) } 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, errorDetail: scopeError.error.message_en, 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 // null only for the company-independent tools on a key whose user has // no company yet (issue #1814): resolveMcpCompanyContext refuses every // company-dependent tool with NO_COMPANY_YET before it gets here. let effectiveCompanyId: string | null = companyId const companyRoutingStartedAt = Date.now() try { const extracted = extractRequestedCompany(rawToolArgs) toolArgs = extracted.toolArgs // Hosts do not reliably enforce inputSchema, so a misspelled // parameter used to be dropped silently (see arg-guard.ts). Thrown // inside this try so it reaches the caller as the structured // VALIDATION_ERROR envelope, never as a half-applied call. const unknownArgKeys = findUnknownArgKeys(tool.inputSchema as Record, toolArgs) if (unknownArgKeys.length > 0) { // Name the shape, not just the mistake: a caller that already sent a // wrong key has no way to guess the right one from a key list alone. const example = shortestExampleFor(tool.inputSchema as Record) throw codedError( 'VALIDATION_ERROR', `Unknown parameter${unknownArgKeys.length > 1 ? 's' : ''} ${unknownArgKeys.map((k) => `"${k}"`).join(', ')} for ${requestedToolName}. ` + `Valid parameters: ${listArgKeys(tool.inputSchema as Record).join(', ') || '(none)'}. Unknown keys are rejected, not ignored.` + (example ? ` A working call looks like: ${example}` : ''), ) } // Optional-company tools resolve (and membership-check) a company only // when the caller names one; anonymous callers have no memberships to // check, so a company_id from them is dropped rather than resolved. const wantsCompanyContext = isCompanyDependentTool(toolName) || (isOptionalCompanyTool(toolName) && !isAnonymous && extracted.requestedCompanyId !== undefined) if (wantsCompanyContext) { 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, // Argument problems are not access problems. Exactly two things in // this try raise VALIDATION_ERROR (the unknown-parameter guard and a // malformed company_id) and neither is a permissions failure. Both // used to log as company_access_denied, which is how 604 rejected // calls read as a tenancy bug for a week. errorKind: structured.error.code === 'VALIDATION_ERROR' ? 'invalid_arguments' : 'company_access_denied', errorMessage: structured.error.message_sv, errorDetail: structured.error.message_en, 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, })) ) } // The McpTool.execute contract takes a string company id. Only the // company-independent tools (search, skills, list_companies) can reach // this point unbound, and none of them dereferences the id as a tenant: // the empty string is a placeholder for their signature, never a scope. const tenantId: string = effectiveCompanyId ?? '' // 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 is all-on inside // hasCapability except connector capabilities without own credentials // (see lib/entitlements). Blocks before any pending op is staged. const requiredCapability = MCP_TOOL_CAPABILITY_MAP[toolName] if (requiredCapability && !(await hasCapability(supabase, tenantId, 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, errorDetail: capError.error.message_en, 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, errorDetail: blocked.error.message_en, 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: tenantId, userId, apiKeyId, toolName, }) const taskStartedAt = Date.now() emitAfterResponse(async () => { try { const rawResult = await tool.execute(toolArgs, tenantId, userId, supabase, actor) const canonicalResult = effectiveCompanyId ? addCompanyToTopLevelNext(rawResult, effectiveCompanyId) : rawResult 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, errorDetail: structured.error.message_en, errorCause: errorCauseTag(err), 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, tenantId, userId, supabase, actor) const canonicalResult = effectiveCompanyId ? addCompanyToTopLevelNext(rawResult, effectiveCompanyId) : rawResult 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, errorDetail: structured.error.message_en, errorCause: errorCauseTag(err), 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 { // Data resources are company-scoped; an unbound key (no company yet, // issue #1814) gets the same NO_COMPANY_YET answer as tools/call. if (!companyId) throw noCompanyYetError() 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}"`) ) } }