import { NextResponse } from 'next/server' import { extractBearerToken, validateApiKey, createServiceClientNoCookies, hasScope, TOOL_SCOPE_MAP, } from '@/lib/auth/api-keys' import { createLogger } from '@/lib/logger' import type { SupabaseClient } from '@supabase/supabase-js' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' import { eventBus } from '@/lib/events/bus' import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { getBranding } from '@/lib/branding/service' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { calculateGrossMargin, calculateCashPosition, calculateExpenseRatio, calculateAvgPaymentDays, } from '@/lib/reports/kpi' import { generateTrialBalance } from '@/lib/reports/trial-balance' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' import { uiWidgets, findUiWidget, WIDGET_MIME_TYPE } from './widgets' import { dataResources, findResource, parseResourceQuery } from './resources' import { prompts, findPrompt } from './prompts' import { skills, findSkill, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills' import { getRiskLevel } from '@/lib/pending-operations/risk-tiers' import { checkIdempotencyKey, storeIdempotencyResponse, hashRequest, IdempotencyKeyReuseError, } from '@/lib/api/idempotency' import { toToolError } from './tool-result' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { findMatchingInvoices } from '@/lib/invoices/invoice-matching' import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' import { closePeriod, lockPeriod, resolvePeriodStatusForDate, type PeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { validateYearEndReadiness, previewYearEndClosing } from '@/lib/core/bookkeeping/year-end-service' import { generateSIEExport } from '@/lib/reports/sie-export' import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { getSuggestedCategories } from '@/lib/transactions/category-suggestions' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { getEmailService } from '@/lib/email/service' import { generateInvoiceEmailHtml, generateInvoiceEmailText, generateInvoiceEmailSubject, } from '@/lib/email/invoice-templates' import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service' import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields' // 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 } from '@/types' // ── Actor context ──────────────────────────────────────────── interface ActorContext { type: 'user' | 'api_key' | 'mcp_oauth' | 'cron' id?: string label?: string } // ── JSON-RPC types ─────────────────────────────────────────── interface JsonRpcRequest { jsonrpc: '2.0' id?: string | number method: string params?: Record } interface JsonRpcResponse { jsonrpc: '2.0' id: string | number | null result?: unknown error?: { code: number; message: string; data?: unknown } } // ── MCP Tool definition ────────────────────────────────────── interface McpToolAnnotations { readOnlyHint?: boolean destructiveHint?: boolean idempotentHint?: boolean openWorldHint?: boolean } interface McpTool { name: string description: string inputSchema: Record outputSchema?: Record annotations: McpToolAnnotations _meta?: { ui: { resourceUri: string } } execute: ( args: Record, companyId: string, userId: string, supabase: SupabaseClient, actor?: ActorContext ) => Promise } // ── Shared constants ───────────────────────────────────────── const log = createLogger('mcp-server') 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 // ── Pending operations staging ─────────────────────────────── interface StageNextHint { description: string tool?: string args?: Record resource?: string } interface StageOptions { /** * When true, validate inputs and return the would-be preview without * inserting into pending_operations or executing any side-effects. Used * by agents to preflight an operation before committing to it. */ dryRun?: boolean /** * Per-operation idempotency key. When supplied, repeat calls with the same * key + same payload return the original response and never re-execute. * Different payload + same key returns IDEMPOTENCY_KEY_REUSE. */ idempotencyKey?: string /** * ISO yyyy-MM-dd date used to look up period_status before staging. When * provided, the response includes a `period_status` envelope so agents and * widgets can detect locked/closed periods without a round-trip. Failure to * resolve (DB blip, missing settings row) leaves the response unchanged — * the DB triggers remain the authoritative gate. */ dateForPeriodCheck?: string } async function stagePendingOperation( supabase: SupabaseClient, companyId: string, userId: string, operationType: string, title: string, params: Record, previewData: Record, actor: ActorContext = { type: 'user' }, next?: StageNextHint, options: StageOptions = {} ): Promise<{ staged: boolean dry_run?: boolean idempotency_replay?: boolean operation_id?: string risk_level: 'low' | 'medium' | 'high' actor: ActorContext message: string preview: Record period_status?: PeriodStatusForDate next?: StageNextHint }> { const riskLevel = getRiskLevel(operationType) const branding = getBranding().appName.toLowerCase() // Resolve period_status once, in parallel with downstream IO when possible. // Failure is non-fatal: the DB triggers are authoritative, so a missing // envelope just degrades the agent's preview UX rather than blocking a write. // We log the failure so a systematic outage (e.g. missing company_settings row, // dropped query) is observable in audit logs rather than silently degraded. let periodStatus: PeriodStatusForDate | undefined if (options.dateForPeriodCheck) { try { periodStatus = await resolvePeriodStatusForDate(supabase, companyId, options.dateForPeriodCheck) } catch (err) { log.warn('resolvePeriodStatusForDate failed', { operationType, companyId, dateForPeriodCheck: options.dateForPeriodCheck, error: err instanceof Error ? err.message : String(err), }) periodStatus = undefined } } // ── Dry-run path: skip both the cache and the insert. Return the preview // so the agent sees exactly what would happen without committing. if (options.dryRun) { return { staged: false, dry_run: true, risk_level: riskLevel, actor, message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`, preview: previewData, ...(periodStatus ? { period_status: periodStatus } : {}), ...(next ? { next } : {}), } } // ── Idempotency check: same key + same payload + same company → return // cached response. companyId is folded into the canonical hash so the // same key UUID submitted under a different company is treated as a // fresh request, not a replay. const requestHash = options.idempotencyKey ? hashRequest({ operationType, params, companyId }) : null if (options.idempotencyKey && requestHash) { const cached = await checkIdempotencyKey(supabase, userId, companyId, options.idempotencyKey, requestHash) if (cached) { return { ...(cached.body as Record), idempotency_replay: true, risk_level: riskLevel, actor, message: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`, 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}`) const response = { staged: true, operation_id: data.id, risk_level: riskLevel, actor, message: `Operation staged for review (risk: ${riskLevel}). Open the ${branding} web app to approve or reject it.`, preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData, ...(periodStatus ? { period_status: periodStatus } : {}), ...(next ? { next } : {}), } 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 } // ── Shared categorization logic ────────────────────────────── async function categorizeTransactionCore( txId: string, category: TransactionCategory, vatTreatment: VatTreatment | 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 }> message?: string transaction?: Transaction }> { // 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.') } 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 const mappingResult = buildMappingResultFromCategory( category, transaction as Transaction, isBusiness, entityType, vatTreatment ) 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) { return { preview: true, category, debit_account: mappingResult.debit_account, credit_account: mappingResult.credit_account, amount: Math.abs(transaction.amount), currency: transaction.currency, 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.', } } // 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, userId, transaction as Transaction, mappingResult, 'user_approved' ) } catch { // Non-critical } return { success: true, journal_entry_created: !!journalEntryId, journal_entry_id: journalEntryId, journal_entry_error: journalEntryError, category, debit_account: mappingResult.debit_account, credit_account: mappingResult.credit_account, amount: Math.abs(transaction.amount), currency: transaction.currency, transaction: transaction as Transaction, } } // ── Output schema helpers ──────────────────────────────────── const PAGINATION_PROPS = { count: { type: 'number', description: 'Number of items in this page' }, total_count: { type: 'number', description: 'Total matching across all pages' }, has_more: { type: 'boolean' }, next_offset: { type: 'number', description: 'Offset for the next page (omitted on last page)' }, } as const const STAGED_OPERATION_SCHEMA = { type: 'object', properties: { staged: { type: 'boolean' }, operation_id: { type: 'string', description: 'UUID of the staged operation, present once persisted' }, risk_level: { type: 'string', enum: ['low', 'medium', 'high'] }, actor: { type: 'object' }, dry_run: { type: 'boolean' }, idempotency_replay: { type: 'boolean' }, message: { type: 'string' }, preview: { type: 'object' }, period_status: { type: 'object', description: 'Fiscal period covering the affärshändelse date. Use to detect locked/closed periods without a round-trip.', properties: { period_id: { type: ['string', 'null'] }, status: { type: 'string', enum: ['open', 'locked', 'closed'] }, lock_date: { type: ['string', 'null'] }, }, }, next: { type: 'object' }, }, required: ['staged', 'risk_level', 'actor', 'message', 'preview'], } as const 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 // ── VAT report computation (shared by gnubok_get_vat_report + gnubok_vat_review_widget) ── // // Maps posted journal entry lines to SKV 4700 rutor. ruta49 covers domestic // output VAT (10/11/12) AND reverse-charge output VAT (30/31/32) per // ML 2023:200 — both must be displayed and netted against ruta48 (input VAT). // // Account → ruta map: // 3001-3008, 3041-3048, 3051-3058, 3071-3078 → ruta05 (all domestic taxable sales — common BAS revenue accounts) // 2611 → ruta10 (output VAT 25%) // 2621 → ruta11 (output VAT 12%) // 2631 → ruta12 (output VAT 6%) // 2614 → ruta30 (reverse-charge output VAT 25%) // 2624 → ruta31 (reverse-charge output VAT 12%) // 2634 → ruta32 (reverse-charge output VAT 6%) // 3308 → ruta39 (EU services sold) // 3305 → ruta40 (export outside EU) // 2641/2645/2647 → ruta48 (all input VAT) // // Posted+reversed status filter: a "reversed" original entry is still part of // its period's books — Skatteverket files VAT period-by-period under // faktureringsmetoden (sale's VAT in invoice-date period; kreditfaktura's // reduction in storno-date period). The original entry stays in its period; // the storno (status 'posted', dated when the credit was issued) lands in // its own period. The two periods file independently; across a year they // arithmetically cancel. *Excluding* 'reversed' would under-report Period N // (the original sale's VAT silently disappears) and over-credit Period N+M // (a reversal with no original) — incorrect per ML 2023:200. /** Common BAS taxable-revenue accounts that contribute to ruta 05. * * Conservative expansion beyond 3001/3002/3003. Excludes 3004 (momsfri, * exempt) and 3108/3305/3308 (handled by ruta35/40/39). 3106 covers the * rare case of taxable EU goods (momspliktig EU-leverans, e.g. when the * buyer's VAT number is invalid). * * Companies using non-standard charts must either book to one of these * or extend the list — gnubok's BAS chart only ships 3001/3002/3003/3004 * by default, but 30xx alternates are common in custom charts. */ const RUTA_05_ACCOUNTS = [ // Domestic sales by VAT rate (canonical BAS) '3001', '3002', '3003', '3005', '3006', '3007', '3008', // Taxable EU goods (momspliktig — buyer's VAT number invalid or buyer is private) '3106', // Domestic services (alternative numbering some companies use) '3041', '3042', '3043', '3044', '3045', '3046', '3047', '3048', // Domestic goods (alternative numbering) '3051', '3052', '3053', '3054', '3055', '3056', '3057', '3058', // Other domestic taxable '3071', '3072', '3073', '3074', '3075', '3076', '3077', '3078', ] as const export interface VatReportResult { period: { type: string; year: number; period: number; start: string; end: string } period_label: string rutor: { ruta05: number; ruta10: number; ruta11: number; ruta12: number ruta30: number; ruta31: number; ruta32: number ruta35: number; ruta39: number; ruta40: number ruta48: number; ruta49: number // Import VAT (post-2015 momsdeklaration path, accounts 2615/2625/2635). // Buyer/importer self-assesses output VAT here and deducts the matching // input via ruta 48 — same mechanic as ruta 30/31/32. ruta60: number; ruta61: number; ruta62: number } summary: string warnings: string[] } export async function computeVatReport( args: Record, companyId: string, supabase: SupabaseClient ): Promise { const periodType = args.period_type as string const year = Number(args.year) const period = Number(args.period) if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { throw new Error('period_type must be: monthly, quarterly, yearly') } if (!year || year < 2000 || year > 2100) throw new Error('year must be between 2000 and 2100') if (periodType === 'monthly' && (period < 1 || period > 12)) throw new Error('period must be 1–12 for monthly') if (periodType === 'quarterly' && (period < 1 || period > 4)) throw new Error('period must be 1–4 for quarterly') let startDate: string let endDate: string if (periodType === 'monthly') { startDate = `${year}-${String(period).padStart(2, '0')}-01` const lastDay = new Date(year, period, 0).getDate() endDate = `${year}-${String(period).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` } else if (periodType === 'quarterly') { const startMonth = (period - 1) * 3 + 1 const endMonth = period * 3 startDate = `${year}-${String(startMonth).padStart(2, '0')}-01` const lastDay = new Date(year, endMonth, 0).getDate() endDate = `${year}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` } else { startDate = `${year}-01-01` endDate = `${year}-12-31` } const { data: lines, error } = await supabase .from('journal_entry_lines') .select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)') .eq('journal_entries.company_id', companyId) .in('journal_entries.status', ['posted', 'reversed']) .gte('journal_entries.entry_date', startDate) .lte('journal_entries.entry_date', endDate) if (error) throw new Error(`Database error: ${error.message}`) const accountTotals = new Map() for (const line of lines ?? []) { const acc = line.account_number const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 } existing.debit += Number(line.debit_amount) || 0 existing.credit += Number(line.credit_amount) || 0 accountTotals.set(acc, existing) } function creditBalance(acc: string): number { const t = accountTotals.get(acc) return t ? Math.round((t.credit - t.debit) * 100) / 100 : 0 } function debitBalance(acc: string): number { const t = accountTotals.get(acc) return t ? Math.round((t.debit - t.credit) * 100) / 100 : 0 } const ruta05 = RUTA_05_ACCOUNTS.reduce((sum, acc) => sum + creditBalance(acc), 0) const ruta10 = creditBalance('2611') const ruta11 = creditBalance('2621') const ruta12 = creditBalance('2631') const ruta30 = creditBalance('2614') const ruta31 = creditBalance('2624') const ruta32 = creditBalance('2634') const ruta35 = creditBalance('3108') // EU intra-community goods supplies (momsfri leverans till EU) const ruta39 = creditBalance('3308') const ruta40 = creditBalance('3305') // Import VAT (since 2015 declared via momsdeklaration, not Tullverket): the // importer books output VAT to 2615/2625/2635 (ruta 60/61/62) and the // matching deductible input to 2645 (rolls into ruta 48 below). const ruta60 = creditBalance('2615') const ruta61 = creditBalance('2625') const ruta62 = creditBalance('2635') const calculatedInput2645 = debitBalance('2645') const calculatedInput2647 = debitBalance('2647') const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647 const ruta49 = Math.round( (ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 + ruta60 + ruta61 + ruta62 - ruta48) * 100 ) / 100 const monthNames = ['Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni', 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December'] let periodLabel: string if (periodType === 'monthly') periodLabel = `${monthNames[period - 1]} ${year}` else if (periodType === 'quarterly') periodLabel = `Q${period} ${year}` else periodLabel = `${year}` // Pre-filing warnings — surface common compliance footguns. // // The matching input for reverse-charge output (2614/2624/2634) lands on // 2645 (EU acquisitions) or 2647 (domestic reverse charge per ML 16:13 — // byggtjänster, electronics > 100k SEK, etc.). Either is a valid mirror; // the warning must fire only when *both* are zero. const warnings: string[] = [] const totalReverseChargeOutput = ruta30 + ruta31 + ruta32 const totalReverseChargeInput = calculatedInput2645 + calculatedInput2647 if (totalReverseChargeOutput > 0 && totalReverseChargeInput === 0) { warnings.push( 'Omvänd betalningsskyldighet: utgående moms har bokförts (rutor 30/31/32) men ingen ' + 'beräknad ingående moms (varken 2645 EU eller 2647 inhemsk). Kontrollera att den ' + 'motsvarande ingående bokningen finns — båda sidor krävs enligt ML 2023:200.' ) } return { 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, } } // ── 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' severity: 'high' | 'medium' | 'low' count: number message: string hint: string } 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[] sanity: { anomalies: VatCloseSanityAnomaly[] ratios: { output_vat_ratio_25: number // ruta10 / domestic 25% revenue output_vat_ratio_12: number output_vat_ratio_6: number previous_period_compared: boolean } } ready_to_close: boolean summary: string } /** Compute the Skatteverket momsdeklaration deadline for a period. * - monthly: due on the 12th of (period-end-month + 1) * - quarterly: 26th of the month after quarter-end (Q4 → 26 Jan next year) * - yearly: 26 Feb of next year */ export function computeMomsDeadline( periodType: 'monthly' | 'quarterly' | 'yearly', year: number, period: number ): { date: string; label: string } | null { if (periodType === 'monthly') { // period 1-12; deadline = 12th of next month const deadlineMonth = period === 12 ? 1 : period + 1 const deadlineYear = period === 12 ? year + 1 : year return { date: `${deadlineYear}-${String(deadlineMonth).padStart(2, '0')}-12`, label: `12 ${monthName(deadlineMonth)} ${deadlineYear}`, } } if (periodType === 'quarterly') { // Q1→26 apr, Q2→26 jul, Q3→26 okt, Q4→26 jan next year const monthByQuarter: Record = { 1: { m: 4, yOffset: 0 }, 2: { m: 7, yOffset: 0 }, 3: { m: 10, yOffset: 0 }, 4: { m: 1, yOffset: 1 }, } const cfg = monthByQuarter[period] if (!cfg) return null return { date: `${year + cfg.yOffset}-${String(cfg.m).padStart(2, '0')}-26`, label: `26 ${monthName(cfg.m)} ${year + cfg.yOffset}`, } } if (periodType === 'yearly') { return { date: `${year + 1}-02-26`, label: `26 februari ${year + 1}`, } } return null } function monthName(m: number): string { return ['januari', 'februari', 'mars', 'april', 'maj', 'juni', 'juli', 'augusti', 'september', 'oktober', 'november', 'december'][m - 1] ?? '' } export async function computeVatCloseCheck( args: Record, companyId: string, supabase: SupabaseClient ): Promise { // 1) VAT report (validates inputs + gives us figures + period dates) const vatReport = await computeVatReport(args, companyId, supabase) const { start, end, type: periodType, year, period } = vatReport.period // 2) Company settings — moms_period drives deadline labelling const { data: settings } = await supabase .from('company_settings') .select('moms_period') .eq('company_id', companyId) .single() const momsPeriod = (settings?.moms_period as 'monthly' | 'quarterly' | 'yearly' | null) ?? null // 3) Deadline — based on the *requested* period type, not company setting, // so the model gets the right deadline even when querying ad-hoc periods. const deadline = computeMomsDeadline( periodType as 'monthly' | 'quarterly' | 'yearly', Number(year), Number(period) ) // 4) Blocker scans — run in parallel const [uncategorizedRes, unapprovedRes, reconRes, missingReceiptsRes] = 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), getReconciliationStatus(supabase, companyId, start, end), // Missing receipts: posted journal entries in period whose gross amount // (sum of debits, equal to sum of credits in a balanced entry) is ≥ // 4 000 SEK and that have no document_attachments. Scoped to entries // originating from bank transactions / supplier invoices / receipts // (where a receipt is legally expected) — skips invoice-payment // entries, year-end entries, etc. // // The 4 000 SEK threshold from ML 17 kap 26–28 § (förenklad faktura) is // expressed inclusive of moms, so we deliberately compare against the // gross. Sum-of-debits equals the gross for ordinary purchase entries // (expense + ingående moms + AP/bank). For EU acquisitions and domestic // reverse-charge buyer entries the calculated VAT lines inflate the // sum, which can pull a sub-threshold purchase above 4 000 — that's a // false positive in favour of asking the user for the receipt, which // is the safe direction. (async () => { const { data: candidates } = await supabase .from('journal_entries') .select( 'id, source_type, document_attachments(id), journal_entry_lines(debit_amount)' ) .eq('company_id', companyId) .in('source_type', ['bank_transaction', 'supplier_invoice', 'receipt']) .in('status', ['posted']) .gte('entry_date', start).lte('entry_date', end) const missing = (candidates ?? []).filter((e) => { const lines = (e.journal_entry_lines ?? []) as { debit_amount: number | string }[] const gross = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0) const docs = e.document_attachments as unknown[] | null return gross >= 4000 && (!docs || docs.length === 0) }) return missing.length })(), ]) const blockers: VatCloseBlocker[] = [] const uncategorizedCount = uncategorizedRes.count ?? 0 if (uncategorizedCount > 0) { blockers.push({ kind: 'uncategorized_transactions', severity: 'high', count: uncategorizedCount, message: `${uncategorizedCount} okategoriserade banktransaktioner i perioden`, hint: 'Kategorisera via gnubok_categorize_transaction eller kör gnubok_auto_match_period.', }) } const unapprovedCount = unapprovedRes.count ?? 0 if (unapprovedCount > 0) { blockers.push({ kind: 'unapproved_supplier_invoices', severity: 'high', count: unapprovedCount, message: `${unapprovedCount} oattesterade leverantörsfakturor i perioden`, hint: 'Attestera via gnubok_approve_supplier_invoice — ingående moms (ruta 48) påverkas.', }) } 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, message: `Bankavstämning visar differens ${reconRes.difference.toFixed(2)} kr (${reconRes.unmatched_transaction_count} omatchade banktransaktioner, ${reconRes.unmatched_gl_line_count} omatchade huvudbokslinjer på 1930)`, hint: 'Granska via gnubok_get_reconciliation_status och matcha — moms beräknas från huvudboken så differenser döljer fel.', }) } const missingReceipts = missingReceiptsRes if (missingReceipts > 0) { blockers.push({ kind: 'missing_high_value_receipts', severity: 'medium', count: missingReceipts, message: `${missingReceipts} bokföringsposter över 4 000 kr saknar bifogat verifikat`, hint: 'BFL 5 kap 6§: varje affärshändelse måste ha verifikat. Använd gnubok_list_unmatched_documents för att para ihop.', }) } // Reverse-charge / import sanity: rutor 30/31/32 are the buyer's calculated // utgående moms on reverse-charge purchases (domestic byggtjänster & // electronics → 2614 → ruta 30; EU acquisitions of goods → 2624 → ruta 31; // EU services → 2634 → ruta 32). Rutor 60/61/62 are the importer's // calculated utgående moms on non-EU imports declared via momsdeklaration // (since 2015 — 2615/2625/2635). All five carry a corresponding ingående // moms entry that lands in ruta 48 (2645 utlandet RC, 2647 domestic RC). // If any of these output rutor are > 0 but ruta 48 is 0, the buyer/importer // booked the output side but forgot the deductible input — ML 2023:200. const acquisitionAndImportBase = vatReport.rutor.ruta30 + vatReport.rutor.ruta31 + vatReport.rutor.ruta32 + vatReport.rutor.ruta60 + vatReport.rutor.ruta61 + vatReport.rutor.ruta62 if (acquisitionAndImportBase > 0 && vatReport.rutor.ruta48 === 0) { blockers.push({ kind: 'reverse_charge_input_missing', severity: 'high', count: 1, message: 'Omvänd skattskyldighet eller import: utgående moms bokförd (ruta 30/31/32 eller 60/61/62) men ingen ingående moms (ruta 48)', hint: 'ML 2023:200: både beräknad utgående moms och avdragsgill ingående moms ska bokföras (2645 utlandet, 2647 inhemskt).', }) } // 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 const readyToClose = highBlockers === 0 const netDue = vatReport.rutor.ruta49 const direction: 'pay' | 'refund' | 'zero' = netDue > 0 ? 'pay' : netDue < 0 ? 'refund' : 'zero' 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 { 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, 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 } // ── Tools ──────────────────────────────────────────────────── export const tools: McpTool[] = [ { name: 'gnubok_search_tools', description: 'Search gnubok MCP tools by keyword and return their schemas at a chosen detail level. Call this first when looking for a capability — avoids loading every tool schema upfront.', inputSchema: { type: 'object', additionalProperties: false, properties: { query: { type: 'string', description: 'Keywords matched against tool name + description (e.g. "vat", "invoice", "categorize"). Empty string returns all tools.' }, detail: { type: 'string', enum: ['name', 'summary', 'full'], description: 'Detail level. name: just names. summary: name + description + scope (default). full: complete schema including inputSchema and outputSchema.' }, scope: { type: 'string', description: 'Optional filter: only tools requiring this API key scope (e.g. "invoices:write").' }, limit: { type: 'number', description: 'Max results, 1–50 (default 20).' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { tools: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, total_matched: { type: 'number' }, detail: { type: 'string' }, }, required: ['tools', 'count', 'total_matched', 'detail'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, _companyId, _userId, _supabase, _actor) { const query = ((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) { candidates = candidates.filter((t) => { const haystack = `${t.name} ${t.description}`.toLowerCase() return haystack.includes(query) }) } 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: t.name, scope: requiredScope } if (detail === 'full') { return { name: t.name, description: t.description, scope: requiredScope, inputSchema: t.inputSchema, ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), annotations: t.annotations, } } // summary (default) return { name: t.name, description: t.description, scope: requiredScope } }) return { tools: projected, count: projected.length, total_matched: totalMatched, detail, } }, }, { name: 'gnubok_list_skills', description: 'List available domain-knowledge skills (workflows for month-end close, VAT review, year-end, invoicing, payroll). Call gnubok_load_skill(slug) to read the body.', inputSchema: { type: 'object', additionalProperties: false, properties: { tag: { type: 'string', description: 'Optional filter — return only skills matching this tag (e.g. "vat", "monthly", "yearly", "payroll").' }, }, }, 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' } }, }, required: ['slug', 'name', 'summary'], }, }, count: { type: 'number' }, }, required: ['skills', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args) { const tag = (args.tag as string | undefined)?.toLowerCase().trim() const filtered = tag ? skills.filter((s) => s.tags.some((t) => t.toLowerCase() === tag)) : skills return { skills: filtered.map((s) => ({ slug: s.slug, name: s.name, summary: s.summary, tags: s.tags, })), count: filtered.length, } }, }, { name: 'gnubok_load_skill', description: 'Load a domain-knowledge skill by slug. Returns the full Markdown body — call gnubok_list_skills first to find slugs.', inputSchema: { type: 'object', additionalProperties: false, properties: { slug: { type: 'string', description: 'Skill slug (e.g. "month-end-close", "quarterly-vat-review", "year-end-close", "invoicing-rules", "payroll-monthly")' }, }, required: ['slug'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { slug: { type: 'string' }, name: { type: 'string' }, summary: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, body: { type: 'string', description: 'Full skill content as Markdown' }, }, required: ['slug', 'name', 'body'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args) { const slug = (args.slug as string | undefined)?.trim() if (!slug) throw new Error('slug is required') const skill = findSkill(slug) if (!skill) { const available = skills.map((s) => s.slug).join(', ') throw new Error(`Skill not found: "${slug}". Available skills: ${available}`) } return { slug: skill.slug, name: skill.name, summary: skill.summary, tags: skill.tags, body: skill.body, } }, }, { name: 'gnubok_create_transactions', description: 'Stage one or more transactions for the user to approve. Each item creates a separate pending operation that the user confirms or rejects in the web app. Useful for ingesting rows from external sources (Airtable, CSVs, etc.). Max 10 per call.', 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.' }, bank_connection_id: { type: 'string', description: 'Optional UUID of a bank_connections row to associate with.' }, external_id: { type: 'string', description: 'Optional external reference (e.g., Airtable record ID). Shown in the preview; the DB enforces uniqueness per user, so the second commit of the same external_id will fail at approval.' }, }, required: ['date', 'amount', 'description'], }, }, }, required: ['transactions'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const items = args.transactions as Array> | undefined if (!Array.isArray(items) || items.length === 0) { throw new Error('transactions must be a non-empty array.') } if (items.length > 10) { throw new Error('transactions exceeds the per-call limit of 10. Split into multiple calls.') } const operations = [] for (let i = 0; i < items.length; i++) { const item = items[i] const date = item.date as string const amount = Number(item.amount) const description = ((item.description as string) ?? '').trim() const currency = ((item.currency as string) || 'SEK').toUpperCase() const 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.`) } const params = { date, amount, description, currency, 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', description: 'List bank transactions with no journal entry yet, newest first. Paginated.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results to return, 1–100 (default 20)' }, offset: { type: 'number', description: 'Number of results to skip for pagination (default 0)' }, }, }, outputSchema: paginatedSchema('transactions', { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, date: { type: 'string' }, description: { type: 'string' }, amount: { type: 'number' }, currency: { type: 'string' }, merchant_name: { type: 'string' }, reference: { type: 'string' }, is_business: { type: 'boolean' }, category: { type: 'string' }, }, }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100) const offset = Math.max(0, Number(args.offset) || 0) // Get total count const { count: totalCount, error: countError } = await supabase .from('transactions') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .is('journal_entry_id', null) if (countError) throw new Error(`Database error: ${countError.message}`) const { data, error } = await supabase .from('transactions') .select( 'id, date, description, amount, currency, merchant_name, reference, is_business, category' ) .eq('company_id', companyId) .is('journal_entry_id', null) .order('date', { ascending: false }) .range(offset, offset + limit - 1) if (error) throw new Error(`Database error: ${error.message}`) const total = totalCount ?? 0 const hasMore = total > offset + (data?.length ?? 0) return { transactions: data, count: data?.length ?? 0, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + (data?.length ?? 0) } : {}), } }, }, { name: 'gnubok_list_transactions_without_documents', description: 'List bank transactions that have a journal entry but no attached receipt/invoice document. Use to find verifikationer that need their kvitto attached for BFL compliance. 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 transactions on or after this date.' }, }, }, outputSchema: paginatedSchema('transactions', { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, date: { type: 'string' }, description: { type: 'string' }, amount: { type: 'number' }, currency: { type: 'string' }, merchant_name: { type: 'string' }, reference: { type: 'string' }, is_business: { type: 'boolean' }, category: { type: 'string' }, journal_entry_id: { type: 'string' }, }, }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100) const offset = Math.max(0, Number(args.offset) || 0) const since = typeof args.since === 'string' ? args.since : null let countQuery = supabase .from('transactions') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .not('journal_entry_id', 'is', null) .is('document_id', null) if (since) countQuery = countQuery.gte('date', since) const { count: totalCount, error: countError } = await countQuery if (countError) throw new Error(`Database error: ${countError.message}`) let dataQuery = supabase .from('transactions') .select( 'id, date, description, amount, currency, merchant_name, reference, is_business, category, journal_entry_id' ) .eq('company_id', companyId) .not('journal_entry_id', 'is', null) .is('document_id', null) if (since) dataQuery = dataQuery.gte('date', since) const { data, error } = await dataQuery .order('date', { ascending: false }) .range(offset, offset + limit - 1) if (error) throw new Error(`Database error: ${error.message}`) const total = totalCount ?? 0 const hasMore = total > offset + (data?.length ?? 0) return { transactions: data, count: data?.length ?? 0, total_count: total, has_more: hasMore, ...(hasMore ? { next_offset: offset + (data?.length ?? 0) } : {}), } }, }, { name: 'gnubok_categorize_transaction', description: 'Categorize a bank transaction. Stages the journal entry for the user to approve in the web app — no DB write until approval.', 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)', enum: [...VALID_VAT_TREATMENTS] }, }, required: ['transaction_id', 'category'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { // Compute the preview (accounts, amounts, VAT lines) const result = await categorizeTransactionCore( args.transaction_id as string, args.category as TransactionCategory, args.vat_treatment as VatTreatment | undefined, userId, companyId, supabase, false // preview mode — execution happens via web UI commit ) // If already has a journal entry, pass through as-is if (result.success && result.journal_entry_created === false) { const { transaction: _tx, ...publicResult } = result return publicResult } // Fetch transaction description (and date for period_status) for the title const { data: tx } = await supabase .from('transactions') .select('description, merchant_name, amount, currency, date') .eq('id', args.transaction_id as string) .eq('company_id', companyId) .single() const txDesc = tx ? `${tx.merchant_name || tx.description || 'Transaktion'} ${tx.amount} ${tx.currency}` : String(args.transaction_id) // Stage for user approval return stagePendingOperation(supabase, companyId, userId, 'categorize_transaction', `Kategorisera: ${txDesc}`, { transaction_id: args.transaction_id, category: args.category, vat_treatment: args.vat_treatment || null, }, { debit_account: result.debit_account, credit_account: result.credit_account, amount: result.amount, currency: result.currency, vat_lines: result.vat_lines || [], category: result.category, }, actor, undefined, tx?.date ? { dateForPeriodCheck: tx.date } : {}, ) }, }, // ── Receipt matcher tool ────────────────────────────────────── { name: 'gnubok_receipt_matcher', description: 'Open an interactive widget for drag-and-drop receipt-to-transaction matching. Renders inline in compatible clients.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max transactions to show, 1–50 (default 20)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { transactions: { type: 'array', items: { type: 'object' } }, categories: { type: 'array', items: { type: 'string' } }, vat_treatments: { type: 'array', items: { type: 'string' } }, }, required: ['transactions', 'categories', 'vat_treatments'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, _meta: { ui: { resourceUri: 'ui://receipt-matcher/app.html' } }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) const { data, error } = await supabase .from('transactions') .select( 'id, date, description, amount, currency, merchant_name, reference, is_business, category' ) .eq('company_id', companyId) .is('journal_entry_id', null) .order('date', { ascending: false }) .limit(limit) if (error) throw new Error(`Database error: ${error.message}`) return { transactions: data ?? [], categories: [...VALID_CATEGORIES], vat_treatments: [...VALID_VAT_TREATMENTS], } }, }, // ── Customer tools ─────────────────────────────────────────── { name: 'gnubok_list_customers', description: 'List all customers for the active company. Use to look up customer_id for invoice creation.', inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', additionalProperties: false, properties: { customers: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['customers', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { const { data, error } = await supabase .from('customers') .select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country') .eq('company_id', companyId) .order('name') if (error) throw new Error(`Database error: ${error.message}`) return { customers: data, count: data?.length ?? 0 } }, }, { name: 'gnubok_create_customer', description: 'Stage a new customer. Stages for user approval — NOT created until approved in the web app. EU VAT numbers trigger VIES validation.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { name: { type: 'string', description: 'Customer name' }, customer_type: { type: 'string', enum: ['individual', 'swedish_business', 'eu_business', 'non_eu_business'], description: 'Customer type', }, email: { type: 'string', description: 'Email address' }, org_number: { type: 'string', description: 'Swedish org number' }, vat_number: { type: 'string', description: 'EU VAT number' }, payment_terms: { type: 'number', description: 'Payment terms in days (default 30)' }, address: { type: 'string', description: 'Street address' }, postal_code: { type: 'string' }, city: { type: 'string' }, country: { type: 'string', description: 'Country (default Sweden)' }, dry_run: { type: 'boolean', description: 'If true, validate inputs and return the would-be preview without staging or creating. No DB writes, no side-effects.', }, idempotency_key: { type: 'string', description: 'Random per-operation UUID. Repeat calls with the same key + same payload return the original response (24h TTL). Different payload → IDEMPOTENCY_KEY_REUSE error.', }, }, required: ['name', 'customer_type'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, // safe to retry with idempotency_key openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const name = args.name as string const customerType = args.customer_type as string if (!name?.trim()) throw new Error('Customer name is required.') if (!['individual', 'swedish_business', 'eu_business', 'non_eu_business'].includes(customerType)) { throw new Error('Invalid customer_type. Must be: individual, swedish_business, eu_business, non_eu_business') } const params = { name: name.trim(), customer_type: customerType, email: (args.email as string) || null, org_number: (args.org_number as string) || null, vat_number: (args.vat_number as string) || null, payment_terms: Number(args.payment_terms) || 30, address: (args.address as string) || null, postal_code: (args.postal_code as string) || null, city: (args.city as string) || null, country: (args.country as string) || 'Sweden', } return stagePendingOperation(supabase, companyId, userId, 'create_customer', `Ny kund: ${params.name}`, params, params, // params ARE the preview for customers actor, { description: 'Once approved, you can invoice this customer with gnubok_create_invoice using the returned customer_id.', tool: 'gnubok_create_invoice', }, { dryRun: Boolean(args.dry_run), idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, } ) }, }, // ── Invoice tools ──────────────────────────────────────────── { name: 'gnubok_list_invoices', description: 'List invoices for the active company, newest first. Optional status filter.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited'], description: 'Filter by invoice status', }, limit: { type: 'number', description: 'Max results (default 50, max 100)' }, }, }, outputSchema: paginatedSchema('invoices', { type: 'object' }), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100) const status = args.status as string | undefined let query = supabase .from('invoices') .select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, customers(name)', { count: 'exact' }) .eq('company_id', companyId) if (status) { query = query.eq('status', status) } const { data, error, count } = await query .order('invoice_date', { ascending: false }) .limit(limit) if (error) throw new Error(`Database error: ${error.message}`) const invoices = (data ?? []).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, })) return { invoices, count: invoices.length, total_count: count ?? invoices.length, } }, }, { name: 'gnubok_create_invoice', description: 'Stage a new invoice. Validates inputs, calculates VAT preview. Stages for user approval — invoice number assigned at approval.', outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', additionalProperties: false, properties: { customer_id: { type: 'string', description: 'Customer UUID' }, items: { type: 'array', items: { type: 'object', properties: { description: { type: 'string' }, quantity: { type: 'number' }, unit: { type: 'string', description: 'st, tim, dag, mån' }, unit_price: { type: 'number', description: 'Price per unit excl. VAT' }, vat_rate: { type: 'number', description: 'VAT rate 0–100 (optional override)' }, }, required: ['description', 'quantity', 'unit', 'unit_price'], }, description: 'Invoice line items', }, invoice_date: { type: 'string', description: 'YYYY-MM-DD (default today)' }, due_date: { type: 'string', description: 'YYYY-MM-DD (default from payment terms)' }, currency: { type: 'string', enum: ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'] }, our_reference: { type: 'string' }, your_reference: { type: 'string' }, notes: { type: 'string' }, }, required: ['customer_id', 'items'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const customerId = args.customer_id as string const items = args.items as Array<{ description: string quantity: number unit: string unit_price: number vat_rate?: number }> if (!customerId) throw new Error('customer_id is required. Use gnubok_list_customers to find IDs.') if (!items?.length) throw new Error('At least one item is required.') for (const [i, item] of items.entries()) { if (!item.description?.trim()) throw new Error(`Item ${i + 1}: description is required`) if (!item.quantity || item.quantity <= 0) throw new Error(`Item ${i + 1}: quantity must be positive`) if (!item.unit?.trim()) throw new Error(`Item ${i + 1}: unit is required (st, tim, dag)`) if (item.unit_price == null) throw new Error(`Item ${i + 1}: unit_price is required`) } const today = new Date().toISOString().split('T')[0] const currency = ((args.currency as string) || 'SEK') as Currency const invoiceDate = (args.invoice_date as string) || today // Fetch customer (full row for VAT rules) const { data: customer, error: custError } = await supabase .from('customers') .select('*') .eq('id', customerId) .eq('company_id', companyId) .single() if (custError || !customer) { throw new Error('Customer not found. Use gnubok_list_customers to find valid IDs.') } // VAT rules from customer type (same logic as web UI) const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated) const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated) const allowedRates = new Set(availableRates.map((r) => r.rate)) // Calculate per-item VAT const subtotal = items.reduce((s, item) => s + item.quantity * item.unit_price, 0) let vatAmount = 0 for (const item of items) { const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate if (!allowedRates.has(itemRate)) { throw new Error( `VAT rate ${itemRate}% is not allowed for customer type "${customer.customer_type}". ` + `Allowed rates: ${availableRates.map((r) => r.rate + '%').join(', ')}` ) } const lineTotal = item.quantity * item.unit_price vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100 } const total = subtotal + vatAmount // Due date from payment terms if not provided let dueDate = args.due_date as string | undefined if (!dueDate) { const d = new Date(invoiceDate) d.setDate(d.getDate() + (customer.default_payment_terms || 30)) dueDate = d.toISOString().split('T')[0] } // Stage for user approval instead of creating directly return stagePendingOperation(supabase, companyId, userId, 'create_invoice', `Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`, { customer_id: customerId, items, invoice_date: invoiceDate, due_date: dueDate, currency, our_reference: (args.our_reference as string) || null, your_reference: (args.your_reference as string) || null, notes: (args.notes as string) || null, }, { customer_name: customer.name, customer_type: customer.customer_type, items: items.map(item => ({ ...item, line_total: item.quantity * item.unit_price, vat_rate: item.vat_rate ?? vatRules.rate, })), subtotal: Math.round(subtotal * 100) / 100, vat_amount: Math.round(vatAmount * 100) / 100, total: Math.round(total * 100) / 100, currency, vat_treatment: vatRules.treatment, invoice_date: invoiceDate, due_date: dueDate, }, actor, { description: 'Once approved, the invoice is created as a draft. Send it with gnubok_send_invoice or use gnubok_mark_invoice_as_sent if delivered outside the system.', tool: 'gnubok_send_invoice', } ) }, }, // ── Report tools ───────────────────────────────────────────── { name: 'gnubok_get_trial_balance', description: 'Trial balance (huvudbok) for a fiscal period — all account balances with debit/credit totals. Defaults to most recent period.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, 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' }, }, required: ['rows', 'total_debit', 'total_credit', 'is_balanced'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined // If no period specified, find the most recent one if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id, name') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) { throw new Error('No fiscal periods found. Categorize some transactions first to auto-create a period.') } periodId = periods.id } // Get period info const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', periodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found.') // Aggregate journal entry lines const { data: lines, error } = await supabase .from('journal_entry_lines') .select('account_number, debit_amount, credit_amount, journal_entries!inner(status, user_id, fiscal_period_id)') .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', periodId) .in('journal_entries.status', ['posted', 'reversed']) if (error) throw new Error(`Database error: ${error.message}`) // Get account names const { data: accounts } = await supabase .from('chart_of_accounts') .select('account_number, account_name') .eq('company_id', companyId) const accountMap = new Map((accounts ?? []).map((a: { account_number: string; account_name: string }) => [a.account_number, a.account_name])) // Aggregate by account const totals = new Map() for (const line of lines ?? []) { const acc = line.account_number const existing = totals.get(acc) ?? { debit: 0, credit: 0 } existing.debit += Number(line.debit_amount) || 0 existing.credit += Number(line.credit_amount) || 0 totals.set(acc, existing) } const rows = Array.from(totals.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([accNum, t]) => { const net = Math.round((t.debit - t.credit) * 100) / 100 return { account_number: accNum, account_name: accountMap.get(accNum) ?? accNum, period_debit: Math.round(t.debit * 100) / 100, period_credit: Math.round(t.credit * 100) / 100, closing_debit: net > 0 ? net : 0, closing_credit: net < 0 ? Math.abs(net) : 0, } }) 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, } }, }, { name: 'gnubok_get_vat_report', description: 'VAT declaration (momsdeklaration, SKV 4700) for a period. Returns all rutor; ruta49 = VAT to pay (positive) or refund (negative).', 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' }, }, required: ['period_type', 'year', 'period'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { return computeVatReport(args, companyId, supabase) }, }, { name: 'gnubok_vat_review_widget', description: 'Open the interactive VAT review widget for a period. Same data as gnubok_get_vat_report, rendered as a tabular UI for pre-filing review.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' }, year: { type: 'number', description: 'Year (e.g. 2025)' }, period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' }, }, required: ['period_type', 'year', 'period'], }, outputSchema: VAT_REPORT_OUTPUT_SCHEMA, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, _meta: { ui: { resourceUri: 'ui://vat-review/app.html' } }, async execute(args, companyId, _userId, supabase) { return computeVatReport(args, companyId, supabase) }, }, { name: 'gnubok_vat_close_check', description: "Answer 'can I close VAT?' in one call. Returns SKV 4700 rutor + blocker scan (uncategorized, unapproved supplier invoices, reconciliation diff, missing receipts ≥ 4000 kr, reverse-charge mirroring) + period sanity ratios + Skatteverket deadline + ready_to_close.", 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' } }, sanity: { type: 'object' }, ready_to_close: { type: 'boolean' }, summary: { type: 'string' }, }, required: ['period', 'rutor', 'payment', 'blockers', 'sanity', 'ready_to_close', 'summary'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { return computeVatCloseCheck(args, companyId, supabase) }, }, // ── KPI & Income Statement tools ───────────────────────────── { name: 'gnubok_get_kpi_report', description: 'Business KPIs for a fiscal period: gross margin, net result, cash position, receivables, expense ratio, payment days, VAT liability, monthly trend.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) { throw new Error('No fiscal periods found. Categorize some transactions first.') } periodId = periods.id } // Verify period belongs to user const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', periodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found.') // Run queries in parallel (same as the KPI API route) const [incomeStatement, trialBalance, arLedger, monthlyBreakdown, paidInvoices] = await Promise.all([ generateIncomeStatement(supabase, companyId, periodId!), generateTrialBalance(supabase, companyId, periodId!), generateARLedger(supabase, companyId), generateMonthlyBreakdown(supabase, companyId, periodId!), supabase .from('invoices') .select('invoice_date, paid_at') .eq('company_id', companyId) .eq('status', 'paid') .not('paid_at', 'is', null), ]) const grossMargin = calculateGrossMargin(incomeStatement) const cashPosition = calculateCashPosition(trialBalance.rows) const expenseRatio = calculateExpenseRatio(incomeStatement) const avgPaymentDays = calculateAvgPaymentDays( (paidInvoices.data ?? []) as { invoice_date: string; paid_at: string }[] ) // AR ledger uses entries, each with invoices that have outstanding amounts const outstandingReceivables = arLedger.total_outstanding const overdueReceivables = arLedger.total_overdue // VAT liability from trial balance const getClosing = (accNum: string) => { const row = trialBalance.rows.find((r) => r.account_number === accNum) if (!row) return 0 return row.closing_credit - row.closing_debit } const vatLiability = Math.round( (getClosing('2611') + getClosing('2621') + getClosing('2631') - getClosing('2641') - getClosing('2645')) * 100 ) / 100 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', description: 'Income statement (resultaträkning) for a fiscal period: revenue, expenses, net result by account category.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) { throw new Error('No fiscal periods found. Categorize some transactions first.') } periodId = periods.id } const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', periodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found.') const result = await generateIncomeStatement(supabase, companyId, periodId!) result.period = { start: period.period_start, end: period.period_end } return { period_name: period.name, ...result, } }, }, // ── Invoice Operations ─────────────────────────────────────── { name: 'gnubok_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)' }, }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invoiceError || !invoice) throw new Error('Invoice not found') if (invoice.status !== 'sent' && invoice.status !== 'overdue') { throw new Error('Invoice can only be marked as paid when status is "sent" or "overdue"') } const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0] 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 }, { invoice_number: invoice.invoice_number, customer_name: invoice.customer?.name, total: invoice.total, currency: invoice.currency, payment_date: paymentDate, }, actor, undefined, { dateForPeriodCheck: paymentDate }, ) }, }, { name: 'gnubok_send_invoice', description: 'Send invoice via email with PDF attachment. Stages for approval. Requires customer email + email service configured.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the invoice to send' }, }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, }, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const emailService = getEmailService() if (!emailService.isConfigured()) { throw new Error('Email service not configured. Ensure RESEND_API_KEY and RESEND_FROM_EMAIL are set.') } const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invoiceError || !invoice) throw new Error('Invoice not found') const customer = invoice.customer as Customer if (!customer.email) throw new Error('Customer has no email address. Update customer details first.') return stagePendingOperation(supabase, companyId, userId, 'send_invoice', `Skicka: ${invoice.invoice_number} till ${customer.email}`, { invoice_id: invoiceId }, { invoice_number: invoice.invoice_number, customer_name: customer.name, customer_email: customer.email, total: invoice.total, currency: invoice.currency, }, actor ) }, }, { name: 'gnubok_mark_invoice_as_sent', description: 'Mark a draft invoice as sent without sending email (when delivered manually). Stages for approval. Status must be draft.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the draft invoice' }, }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const invoiceId = args.invoice_id as string if (!invoiceId) throw new Error('invoice_id is required') const { data: invoice, error: invoiceError } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invoiceError || !invoice) throw new Error('Invoice not found') if (invoice.status !== 'draft') throw new Error('Only draft invoices can be marked as sent') return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_sent', `Markera skickad: ${invoice.invoice_number} ${invoice.customer?.name || ''}`, { invoice_id: invoiceId }, { invoice_number: invoice.invoice_number, customer_name: invoice.customer?.name, total: invoice.total, currency: invoice.currency, }, actor ) }, }, // ── Supplier Operations (Read-Only) ────────────────────────── { name: 'gnubok_list_suppliers', description: 'List all suppliers (leverantörer) with contact and payment details, sorted by name.', inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', additionalProperties: false, properties: { suppliers: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['suppliers', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { const { data, error } = await supabase .from('suppliers') .select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country') .eq('company_id', companyId) .order('name', { ascending: true }) if (error) throw new Error(`Database error: ${error.message}`) return { suppliers: data ?? [], count: data?.length ?? 0 } }, }, { name: 'gnubok_list_supplier_invoices', description: 'List supplier invoices (leverantörsfakturor), sorted by due date. Optional status filter; "to_pay" combines approved+overdue.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', description: 'Filter: registered, approved, overdue, paid, to_pay, all (default)', enum: ['registered', 'approved', 'overdue', 'paid', 'to_pay', 'all'], }, limit: { type: 'number', description: 'Max results 1–100 (default 50)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { invoices: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['invoices', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100) const status = (args.status as string) || 'all' let query = supabase .from('supplier_invoices') .select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, supplier:suppliers(id, name)') .eq('company_id', companyId) if (status !== 'all') { if (status === 'to_pay') { query = query.in('status', ['approved', 'overdue']) } else { query = query.eq('status', status) } } const { data, error } = await query.order('due_date', { ascending: true }).limit(limit) if (error) throw new Error(`Database error: ${error.message}`) return { invoices: data ?? [], count: data?.length ?? 0 } }, }, // ── Counterparty Templates & Suggestions ───────────────────── { name: 'gnubok_get_counterparty_templates', description: 'List active counterparty categorization templates — learned patterns from prior categorizations used for auto-matching new transactions.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results 1–200 (default 100)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { templates: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['templates', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 100), 200) const { data, error } = await supabase .from('categorization_templates') .select('id, counterparty_name, counterparty_aliases, debit_account, credit_account, vat_treatment, vat_account, category, line_pattern, occurrence_count, confidence, last_seen_date, source') .eq('company_id', companyId) .eq('is_active', true) .order('occurrence_count', { ascending: false }) .limit(limit) if (error) throw new Error(`Database error: ${error.message}`) return { templates: (data ?? []).map((t) => ({ ...t, counterparty_name_display: formatCounterpartyName(t.counterparty_name), })), count: data?.length ?? 0, } }, }, { name: 'gnubok_suggest_categories', description: 'Suggest categories for uncategorized transactions using mapping rules, pattern matching, history, and counterparty templates. Up to 20 transactions per call.', 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' }, }, required: ['suggestions', 'counterparty_matches'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const ids = args.transaction_ids as string[] if (!ids || ids.length === 0) throw new Error('transaction_ids is required (non-empty array)') const limitedIds = ids.slice(0, 20) // Fetch transactions const { data: transactions, error: txError } = await supabase .from('transactions') .select('*') .eq('company_id', companyId) .in('id', limitedIds) if (txError) throw new Error(`Database error: ${txError.message}`) if (!transactions || transactions.length === 0) throw new Error('No transactions found') // Fetch mapping rules const { data: mappingRules } = await supabase .from('mapping_rules') .select('*') .or(`company_id.eq.${companyId},company_id.is.null`) .eq('is_active', true) .order('priority', { ascending: false }) // Build category history from past categorizations const { data: historicalTxns } = await supabase .from('transactions') .select('category') .eq('company_id', companyId) .not('is_business', 'is', null) .neq('category', 'uncategorized') .neq('category', 'private') .limit(200) const categoryHistory: Record = {} for (const tx of historicalTxns || []) { if (tx.category) categoryHistory[tx.category] = (categoryHistory[tx.category] || 0) + 1 } // 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 ?? [], categoryHistory ) 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, } } } return { suggestions, counterparty_matches: counterpartyResult } }, }, // ── Accounts & Chart of Accounts ───────────────────────────── { name: 'gnubok_list_accounts', description: 'List chart of accounts (kontoplan). account_class: 1=assets, 2=liabilities, 3=revenue, 4–7=expenses, 8=financial.', inputSchema: { type: 'object', additionalProperties: false, properties: { account_class: { type: 'number', description: 'Filter by class (1–8)' }, active_only: { type: 'boolean', description: 'Only active accounts (default: true)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { accounts: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['accounts', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const activeOnly = args.active_only !== false const accountClass = args.account_class as number | undefined let query = supabase .from('chart_of_accounts') .select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description') .eq('company_id', companyId) .order('sort_order') if (activeOnly) query = query.eq('is_active', true) if (accountClass !== undefined) query = query.eq('account_class', accountClass) const { data, error } = await query if (error) throw new Error(`Database error: ${error.message}`) return { accounts: data ?? [], count: data?.length ?? 0 } }, }, // ── Reports ────────────────────────────────────────────────── { name: 'gnubok_get_balance_sheet', description: 'Balance sheet (balansräkning) for a fiscal period: assets, equity, and liabilities sections with totals + balance check.', inputSchema: { type: 'object', additionalProperties: false, properties: { period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) throw new Error('No fiscal periods found. Create one first.') periodId = periods.id } const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', periodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found.') const result = await generateBalanceSheet(supabase, companyId, periodId!) return { period_name: period.name, ...result, period: { start: period.period_start, end: period.period_end }, } }, }, { name: 'gnubok_get_general_ledger', description: 'General ledger (huvudbok) for a fiscal period: per-account opening balance, entries, closing balance. Optional account range filter.', 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' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { let periodId = args.period_id as string | undefined if (!periodId) { const { data: periods } = await supabase .from('fiscal_periods') .select('id') .eq('company_id', companyId) .order('period_start', { ascending: false }) .limit(1) .single() if (!periods) throw new Error('No fiscal periods found.') periodId = periods.id } const accountFrom = args.account_from as string | undefined const accountTo = args.account_to as string | undefined return await generateGeneralLedger(supabase, companyId, periodId!, accountFrom, accountTo) }, }, { name: 'gnubok_query_journal', description: "Flexible journal-line query — replaces chained ledger calls for ad-hoc questions. Filters: accounts, date range, amount range, voucher series/number, source type, status, project, cost center, free-text. Returns lines with parent voucher metadata + totals.", 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', description: 'Free-text search in entry description and line description' }, voucher_series: { type: 'string', description: 'Filter by voucher series (e.g. "A")' }, voucher_number_from: { type: 'number', description: 'Lowest voucher number (inclusive)' }, voucher_number_to: { type: 'number', description: 'Highest voucher number (inclusive)' }, source_type: { type: 'string', description: 'Filter by source: bank_transaction, invoice_created, supplier_invoice, currency_revaluation, year_end, opening_balance, etc.' }, status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' }, project: { type: 'string', description: 'Filter by project code' }, cost_center: { type: 'string', description: 'Filter by cost center' }, limit: { type: 'number', description: 'Max lines returned 1–500 (default 100). Aggregate totals are computed over the full match set even when truncated.' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { lines: { type: 'array', items: { type: 'object' } }, truncated: { type: 'boolean', description: 'True if more matching lines exist than were returned' }, total_lines: { type: 'number', description: 'Total lines matching ALL filters (incl. amount). When amount_min/amount_max is set this reflects the filtered set, not the wider DB-side match.' }, returned_lines: { type: 'number' }, amount_filter_applied_post_fetch: { type: 'boolean', description: 'True if amount_min/amount_max was applied client-side after the DB fetch.' }, db_matched_pre_amount_filter: { type: ['number', 'null'], description: 'Pre-amount-filter DB match count when amount_filter_applied_post_fetch is true; null otherwise.' }, totals: { type: 'object', properties: { debit: { type: 'number' }, credit: { type: 'number' }, net: { type: 'number', description: 'debit minus credit (positive = net debit)' }, }, }, applied_filters: { type: 'object' }, }, required: ['lines', 'total_lines', 'returned_lines', 'totals'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 100), 500) const status = (args.status as string) || 'posted' const accounts = args.accounts as string[] | undefined const accountFrom = args.account_from as string | undefined const accountTo = args.account_to as string | undefined if (accounts && accounts.length > 50) { throw new Error('accounts list capped at 50 — use account_from/account_to for ranges') } // Build the line-level query with a forced inner join on journal_entries // so we can filter by the parent's company_id, status, date range, etc. let query = supabase .from('journal_entry_lines') .select( 'id, account_number, debit_amount, credit_amount, currency, line_description, project, cost_center, sort_order, journal_entries!inner(id, voucher_number, voucher_series, entry_date, description, source_type, status, company_id)', { count: 'exact' } ) .eq('journal_entries.company_id', companyId) if (status === 'all') { query = query.in('journal_entries.status', ['posted', 'reversed']) } else { query = query.eq('journal_entries.status', status) } if (accounts && accounts.length > 0) { query = query.in('account_number', accounts) } else { if (accountFrom) query = query.gte('account_number', accountFrom) if (accountTo) query = query.lte('account_number', accountTo) } const dateFrom = args.date_from as string | undefined const dateTo = args.date_to as string | undefined if (dateFrom) query = query.gte('journal_entries.entry_date', dateFrom) if (dateTo) query = query.lte('journal_entries.entry_date', dateTo) const voucherSeries = args.voucher_series as string | undefined if (voucherSeries) query = query.eq('journal_entries.voucher_series', voucherSeries) const vnFrom = args.voucher_number_from as number | undefined const vnTo = args.voucher_number_to as number | undefined if (typeof vnFrom === 'number') query = query.gte('journal_entries.voucher_number', vnFrom) if (typeof vnTo === 'number') query = query.lte('journal_entries.voucher_number', vnTo) const sourceType = args.source_type as string | undefined if (sourceType) query = query.eq('journal_entries.source_type', sourceType) const project = args.project as string | undefined if (project) query = query.eq('project', project) const costCenter = args.cost_center as string | undefined if (costCenter) query = query.eq('cost_center', costCenter) // Free-text search across both line description and entry description. // PostgREST `or` filter applies at the joined level when fully qualified. const text = (args.text as string | undefined)?.trim() if (text) { // Escape both LIKE wildcards (`%` and `_`) so a search for "2_441" // matches the literal string instead of "2X441". Replace `,` with a // space because PostgREST treats it as the `or` separator. const escaped = text.replace(/[%]/g, '\\%').replace(/_/g, '\\_').replace(/,/g, ' ') query = query.or( `line_description.ilike.%${escaped}%,journal_entries.description.ilike.%${escaped}%` ) } // Order by date desc then voucher_number desc — most recent first query = query .order('entry_date', { foreignTable: 'journal_entries', ascending: false }) .order('voucher_number', { foreignTable: 'journal_entries', ascending: false }) .order('sort_order', { ascending: true }) .limit(limit) const { data, error, count } = await query if (error) throw new Error(`Database error: ${error.message}`) 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 sort_order: number journal_entries: { id: string voucher_number: number voucher_series: string entry_date: string description: string source_type: string status: string } } // 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. 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 filtered = (data ?? []).filter((row) => { const r = row as unknown as LineRow 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 }) as unknown as LineRow[] // Compute totals on the fetched-and-filtered set. Note: when truncated, // these are totals of the returned slice, not the full match. The // truncated flag tells the agent whether to issue a narrower query. let totalDebit = 0 let totalCredit = 0 const lines = filtered.map((r) => { totalDebit += Number(r.debit_amount) || 0 totalCredit += Number(r.credit_amount) || 0 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, 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, currency: r.currency, } }) // PostgREST's `count` is computed before the post-fetch amount filter, // so when amount_min/amount_max is set it reflects the wider DB-side // match — not the lines actually returned. Reporting that as // `total_lines` would mislead an agent into chasing a truncated tail // that has already been filtered out client-side. When the amount // filter ran, anchor `total_lines` and `truncated` to the filtered // result, and surface the pre-filter count + a flag separately so an // agent can still tell the DB matched more (it just didn't pass the // amount predicate). const dbMatched = count ?? (data ?? []).length const total_lines = amountFilterApplied ? lines.length : dbMatched const truncated = amountFilterApplied ? (data ?? []).length >= limit && lines.length === limit : dbMatched > lines.length return { lines, truncated, total_lines, returned_lines: lines.length, amount_filter_applied_post_fetch: amountFilterApplied, db_matched_pre_amount_filter: amountFilterApplied ? dbMatched : null, totals: { debit: Math.round(totalDebit * 100) / 100, credit: Math.round(totalCredit * 100) / 100, net: Math.round((totalDebit - totalCredit) * 100) / 100, }, 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, }, } }, }, { name: 'gnubok_get_ar_ledger', description: 'Accounts receivable ledger (kundreskontra): outstanding customer invoices with aging.', inputSchema: { type: 'object', additionalProperties: false, properties: { as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default: today)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const asOfDate = args.as_of_date as string | undefined return await generateARLedger(supabase, companyId, asOfDate) }, }, { name: 'gnubok_get_supplier_ledger', description: 'Accounts payable ledger (leverantörsreskontra): outstanding supplier invoices with aging.', inputSchema: { type: 'object', additionalProperties: false, properties: { as_of_date: { type: 'string', description: 'Balance date YYYY-MM-DD (default: today)' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const asOfDate = args.as_of_date as string | undefined return await generateSupplierLedger(supabase, companyId, asOfDate) }, }, // ── Transaction Matching ───────────────────────────────────── { name: 'gnubok_match_transaction_to_invoice', description: 'Match a bank transaction (income, amount>0) to a customer invoice. Stages for approval. Supports partial payments and auto-storno of prior categorization.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string', description: 'UUID of the bank transaction' }, invoice_id: { type: 'string', description: 'UUID of the invoice to match' }, }, required: ['transaction_id', 'invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string const invoiceId = args.invoice_id as string if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required') // Validate both exist and are matchable const { data: transaction, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, invoice_id') .eq('id', transactionId) .eq('company_id', companyId) .single() if (txError || !transaction) throw new Error('Transaction not found') if (transaction.amount <= 0) throw new Error('Only income transactions (amount > 0) can be matched to invoices') if (transaction.invoice_id) throw new Error('Transaction is already linked to an invoice') const { data: invoice, error: invError } = await supabase .from('invoices') .select('*, customer:customers(*)') .eq('id', invoiceId) .eq('company_id', companyId) .single() if (invError || !invoice) throw new Error('Invoice not found') if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') { throw new Error('Invoice is not in a matchable state (must be sent, overdue, or partially_paid)') } const txDesc = transaction.merchant_name || transaction.description || transactionId return stagePendingOperation(supabase, companyId, userId, 'match_transaction_invoice', `Matcha: ${txDesc} → ${invoice.invoice_number}`, { transaction_id: transactionId, invoice_id: invoiceId }, { transaction_description: txDesc, transaction_amount: transaction.amount, transaction_currency: transaction.currency, invoice_number: invoice.invoice_number, invoice_total: invoice.total, invoice_currency: invoice.currency, customer_name: (invoice.customer as Record)?.name as string, }, actor ) }, }, { name: 'gnubok_auto_match_period', description: "Bulk reconciliation: scan unmatched income transactions in a date range and propose invoice matches with confidence + reasoning. dry_run=true (default) previews without staging; dry_run=false stages every match above confidence_threshold as a pending operation.", 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', description: 'List all fiscal periods (räkenskapsperioder) with status: active (open), locked (no new entries), or closed (year-end completed).', inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', additionalProperties: false, properties: { periods: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['periods', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(_args, companyId, userId, supabase) { const { data, error } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at, opening_balances_set') .eq('company_id', companyId) .order('period_start', { ascending: false }) if (error) throw new Error(`Database error: ${error.message}`) const periods = (data ?? []).map((p) => ({ id: p.id, name: p.name, period_start: p.period_start, period_end: p.period_end, opening_balances_set: p.opening_balances_set, status: p.is_closed ? 'closed' : p.locked_at ? 'locked' : 'active', })) return { periods, count: periods.length } }, }, // ── Reconciliation ─────────────────────────────────────────── { name: 'gnubok_get_reconciliation_status', description: 'Bank reconciliation status: matched/unmatched counts, match rate, bank vs ledger balance, difference. Optional date range.', inputSchema: { type: 'object', additionalProperties: false, properties: { date_from: { type: 'string', description: 'Start date YYYY-MM-DD' }, date_to: { type: 'string', description: 'End date YYYY-MM-DD' }, }, }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const dateFrom = args.date_from as string | undefined const dateTo = args.date_to as string | undefined return await getReconciliationStatus(supabase, companyId, dateFrom, dateTo) }, }, // ── Document Inbox Tools ──────────────────────────────────── { name: 'gnubok_upload_document', description: 'Upload a PDF/JPEG/PNG/HEIC/WebP (max 20 MB) to the inbox. Runs deterministic field extraction on text-based PDFs.', inputSchema: { type: 'object', additionalProperties: false, properties: { file_name: { type: 'string', description: 'File name with extension (e.g. "faktura.pdf")' }, file_content_base64: { type: 'string', description: 'Base64-encoded file content' }, mime_type: { type: 'string', description: 'MIME type (optional, inferred from extension)' }, }, required: ['file_name', 'file_content_base64'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string' }, inbox_item_id: { type: 'string' }, status: { type: 'string' }, extracted_data: { type: 'object' }, matched_supplier_id: { type: 'string' }, }, required: ['document_id', 'inbox_item_id', 'status'], }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const fileName = args.file_name as string const base64Content = args.file_content_base64 as string let mimeType = args.mime_type as string | undefined if (!mimeType) { const ext = 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 = ext ? mimeMap[ext] : undefined if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${ext}`) } const allowedMimeTypes = new Set([ 'application/pdf', 'image/jpeg', 'image/png', 'image/heic', 'image/webp', ]) if (!allowedMimeTypes.has(mimeType)) { throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`) } 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' }) const { data: extracted } = await extractInvoiceFields({ buffer, mimeType, fileName, }) let matchedSupplierId: string | null = null if (extracted.supplier.orgNumber) { const { data: s } = await supabase .from('suppliers') .select('id') .eq('company_id', companyId) .eq('org_number', extracted.supplier.orgNumber) .limit(1) .maybeSingle() if (s) matchedSupplierId = s.id } const { data: inbox, error: inboxError } = await supabase .from('invoice_inbox_items') .insert({ company_id: companyId, user_id: userId, status: 'received', source: 'upload', document_id: doc.id, extracted_data: extracted as unknown as Record, matched_supplier_id: matchedSupplierId, }) .select('id, status') .single() if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`) return { document_id: doc.id, inbox_item_id: inbox.id, status: inbox.status, extracted_data: extracted, matched_supplier_id: matchedSupplierId, } }, }, { name: 'gnubok_list_inbox_items', description: 'List document inbox items (received supplier-invoice documents). Optional status filter.', inputSchema: { type: 'object', additionalProperties: false, properties: { status: { type: 'string', enum: ['received', 'error'], description: 'Filter by status' }, limit: { type: 'number', description: 'Max results (default 20, max 50)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { items: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['items', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) const status = args.status as string | undefined let query = supabase .from('invoice_inbox_items') .select('id, status, source, created_at, extracted_data, matched_supplier_id, created_supplier_invoice_id, email_from, email_subject, error_message') .eq('company_id', companyId) .order('created_at', { ascending: false }) .limit(limit) if (status) query = query.eq('status', status) const { data, error } = await query if (error) throw new Error(`Database error: ${error.message}`) const items = (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 } return { id: item.id, status: item.status, source: item.source, created_at: item.created_at, vendor_name: vendorName, amount, invoice_date: invoiceDate, matched_supplier_id: item.matched_supplier_id, created_supplier_invoice_id: item.created_supplier_invoice_id, email_from: item.email_from, email_subject: item.email_subject, error_message: item.error_message, } }) return { items, count: items.length } }, }, { name: 'gnubok_get_inbox_item', description: 'Get a single inbox item with complete extracted data, supplier match, email metadata, and timestamps.', inputSchema: { type: 'object', additionalProperties: false, properties: { inbox_item_id: { type: 'string', description: 'UUID of the inbox item' }, }, required: ['inbox_item_id'], }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const id = args.inbox_item_id as string const { data, error } = await supabase .from('invoice_inbox_items') .select('*, document_attachments(id, file_name, mime_type, file_size_bytes, created_at)') .eq('id', id) .eq('company_id', companyId) .single() if (error) throw new Error(`Database error: ${error.message}`) if (!data) throw new Error('Inbox item not found') return data }, }, { name: 'gnubok_create_supplier_invoice_from_inbox', description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier (matched or via org_number/name), assembles line items from extracted_data, applies VAT + FX, attaches the source document. Stages for human review; honors dry_run.", 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' }, due_date_override: { type: 'string', description: 'Override extracted due date (YYYY-MM-DD)' }, notes: { type: 'string', description: 'Optional notes appended to the supplier invoice' }, dry_run: { type: 'boolean', description: 'If true, return the assembled payload without staging (default false)' }, idempotency_key: { type: 'string', description: 'UUID. Repeat calls with same key + payload return cached response.' }, }, required: ['inbox_item_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const inboxItemId = args.inbox_item_id as string if (!inboxItemId) throw new Error('inbox_item_id is required') const dryRun = args.dry_run === true const idempotencyKey = args.idempotency_key as string | undefined // Fetch the inbox item with the attached source document const { data: inbox, error: inboxErr } = await supabase .from('invoice_inbox_items') .select('id, status, extracted_data, matched_supplier_id, created_supplier_invoice_id, document_id') .eq('id', inboxItemId) .eq('company_id', companyId) .single() if (inboxErr || !inbox) throw new Error('Inbox item not found') if (inbox.created_supplier_invoice_id) { throw new Error(`Inbox item already converted to supplier invoice ${inbox.created_supplier_invoice_id}`) } const extracted = (inbox.extracted_data as Record | null) ?? null if (!extracted) throw new Error('Inbox item has no extracted_data — re-run extraction first') const supplierExt = extracted.supplier as Record | undefined const invoiceExt = extracted.invoice as Record | undefined const totalsExt = extracted.totals as Record | undefined const lineItemsExt = (extracted.lineItems as Array> | undefined) ?? [] // Resolve supplier — explicit override > matched > org_number lookup > name lookup const supplierIdOverride = args.supplier_id_override as string | undefined let supplierId: string | null = supplierIdOverride ?? (inbox.matched_supplier_id as string | null) ?? null let supplierResolution: 'override' | 'matched' | 'lookup_org_number' | 'lookup_name' | 'unresolved' = supplierIdOverride ? 'override' : inbox.matched_supplier_id ? 'matched' : 'unresolved' if (!supplierId) { const orgNumber = supplierExt?.organizationNumber as string | undefined const supplierName = supplierExt?.name as string | undefined if (orgNumber) { const { data } = await supabase .from('suppliers') .select('id') .eq('company_id', companyId) .eq('org_number', orgNumber) .maybeSingle() if (data) { supplierId = data.id supplierResolution = 'lookup_org_number' } } if (!supplierId && supplierName) { const { data } = await supabase .from('suppliers') .select('id') .eq('company_id', companyId) .ilike('name', supplierName) .maybeSingle() if (data) { supplierId = data.id supplierResolution = 'lookup_name' } } } if (!supplierId) { throw new Error( `Cannot resolve supplier from extracted data. Pass supplier_id_override, or create the supplier first (extracted name: ${supplierExt?.name ?? 'unknown'}, org: ${supplierExt?.organizationNumber ?? 'unknown'}).` ) } // Assemble core invoice fields const currency = (invoiceExt?.currency as string) || 'SEK' const invoiceDate = (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 const vatAmount = Number(totalsExt?.vat) || 0 // VAT treatment: explicit override wins, else heuristic from extracted data const vatTreatment = (args.vat_treatment_override as string | undefined) ?? (invoiceExt?.vatTreatment as string | undefined) ?? 'standard_25' // FX: if non-SEK, fetch rate at fakturadatum (best-effort; agent can re-stage on failure) let exchangeRate: number | null = null if (currency !== 'SEK' && invoiceDate) { try { const result = await fetchExchangeRate(currency as Currency, new Date(invoiceDate)) exchangeRate = result?.rate ?? null } catch { exchangeRate = null // Agent will be informed via preview; can override later } } // Translate extracted line items into the supplier_invoice_items shape. // Default account 4000 (varuinköp/inköp) when extraction didn't pin one. const lineItems = lineItemsExt.map((li, idx) => ({ line_number: idx + 1, description: (li.description as string) ?? `Position ${idx + 1}`, quantity: Number(li.quantity) || 1, unit: (li.unit as string) ?? 'st', unit_price: Number(li.unit_price ?? li.unitPrice ?? li.amount) || 0, line_total: Number(li.line_total ?? li.lineTotal ?? li.amount) || 0, account_number: (li.account_number as string | undefined) ?? '4000', vat_rate: Number(li.vat_rate ?? li.vatRate) || 0, vat_amount: Number(li.vat_amount ?? li.vatAmount) || 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, } const previewData = { inbox_item_id: inboxItemId, supplier_id: supplierId, supplier_resolution: supplierResolution, extracted_supplier_name: supplierExt?.name ?? null, extracted_org_number: supplierExt?.organizationNumber ?? null, supplier_invoice_number: supplierInvoiceNumber, invoice_date: invoiceDate, due_date: dueDate, currency, exchange_rate: exchangeRate, exchange_rate_source: exchangeRate !== null ? 'riksbanken' : currency === 'SEK' ? 'not_applicable' : 'lookup_failed', vat_treatment: vatTreatment, subtotal: params.subtotal, vat_amount: params.vat_amount, total: params.total, line_count: lineItems.length, items_preview: lineItems.slice(0, 5), 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', description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. The amount is in the invoice currency — FX-normalise before comparing to transactions.amount.', inputSchema: { type: 'object', additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results (default 20, max 50)' }, cursor: { type: 'string', description: 'Composite "__" from previous page (exclusive). Pass next_cursor verbatim.' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { items: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, next_cursor: { type: 'string', description: 'Pass as cursor on next call. Absent = no more pages.' }, }, required: ['items', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50) const cursor = typeof args.cursor === 'string' ? args.cursor : null // Composite cursor: "__". Falls back to plain timestamp // for backward compat with older callers. let cursorTs: string | null = null let cursorId: string | null = null if (cursor) { const sep = cursor.indexOf('__') if (sep === -1) { cursorTs = cursor } else { cursorTs = cursor.slice(0, sep) cursorId = cursor.slice(sep + 2) } } // Pull recent inbox items with a document, no supplier invoice yet, then // filter out those whose document is already pinned to a transaction. // Two-step query because PostgREST doesn't expose anti-joins. const fetchSize = limit * 2 let inboxQuery = supabase .from('invoice_inbox_items') .select('id, document_id, source, email_from, email_subject, email_received_at, extracted_data, created_at') .eq('company_id', companyId) .not('document_id', 'is', null) .is('created_supplier_invoice_id', null) .order('created_at', { ascending: false }) .order('id', { ascending: false }) .limit(fetchSize) if (cursorTs && cursorId) { // (created_at, id) < (cursorTs, cursorId) — keyset pagination inboxQuery = inboxQuery.or( `created_at.lt.${cursorTs},and(created_at.eq.${cursorTs},id.lt.${cursorId})` ) } else if (cursorTs) { inboxQuery = inboxQuery.lt('created_at', cursorTs) } const { data: inboxRows, error: inboxError } = await inboxQuery if (inboxError) throw new Error(`Database error: ${inboxError.message}`) if (!inboxRows || inboxRows.length === 0) { return { items: [], count: 0 } } const docIds = inboxRows.map((r) => r.document_id).filter((d): d is string => d != null) const { data: txMatches, error: txError } = await supabase .from('transactions') .select('document_id') .eq('company_id', companyId) .in('document_id', docIds) if (txError) throw new Error(`Database error: ${txError.message}`) const matchedDocIds = new Set((txMatches || []).map((t) => t.document_id)) const unmatched = inboxRows .filter((r) => r.document_id && !matchedDocIds.has(r.document_id)) .slice(0, limit) .map((item) => { const extracted = item.extracted_data as Record | null let vendorName: string | null = null let orgNumber: string | null = null let amount: number | null = null let currency: string | null = null let invoiceDate: string | null = null let paymentReference: string | null = null if (extracted) { const supplier = extracted.supplier as Record | undefined const invoice = extracted.invoice as Record | undefined const totals = extracted.totals as Record | undefined vendorName = (supplier?.name as string) || null orgNumber = (supplier?.orgNumber as string) || null amount = (totals?.total as number) || null // Surface currency alongside amount so the agent doesn't compare a // non-SEK invoice numerically to a SEK transaction. transactions.amount // is in transactions.currency; if these don't match, the agent must // FX-normalise before ranking matches. Defaulting to null when absent // (rather than 'SEK') makes the missing-currency case explicit. currency = (invoice?.currency as string) || null invoiceDate = (invoice?.invoiceDate as string) || null paymentReference = (invoice?.paymentReference as string) || null } return { inbox_item_id: item.id, document_id: item.document_id, source: item.source, created_at: item.created_at, email_from: item.email_from, email_subject: item.email_subject, email_received_at: item.email_received_at, vendor_name: vendorName, org_number: orgNumber, amount, currency, invoice_date: invoiceDate, payment_reference: paymentReference, } }) // Pagination contract: emit next_cursor whenever the caller might be // missing rows. Two cases: // (a) slice was full → cursor on last returned item (next page picks up // any leftover unmatched rows we filtered past); // (b) slice was short but inbox query returned a full batch → cursor on // last inspected row (more unmatched may exist deeper in the inbox). // Only suppress the cursor when we exhausted the inbox stream entirely. let nextCursor: string | null = null if (unmatched.length === limit) { const last = unmatched[unmatched.length - 1] nextCursor = `${last.created_at}__${last.inbox_item_id}` } else if (inboxRows.length === fetchSize) { const last = inboxRows[inboxRows.length - 1] nextCursor = `${last.created_at}__${last.id}` } return { items: unmatched, count: unmatched.length, ...(nextCursor ? { next_cursor: nextCursor } : {}), } }, }, { name: 'gnubok_get_document_content', description: 'Get a 5-minute signed download URL for a document so the agent can read its contents (e.g. with vision). Use after gnubok_list_unmatched_documents to inspect a specific PDF before deciding which transaction it matches.', inputSchema: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string', description: 'UUID of the document_attachments row' }, }, required: ['document_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { document_id: { type: 'string' }, file_name: { type: 'string' }, mime_type: { type: 'string' }, size_bytes: { type: 'number' }, signed_url: { type: 'string' }, expires_at: { type: 'string' }, }, required: ['document_id', 'file_name', 'signed_url', 'expires_at'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const documentId = args.document_id as string if (!documentId) throw new Error('document_id is required') const { data: doc, error: docError } = await supabase .from('document_attachments') .select('id, file_name, mime_type, file_size_bytes, storage_path') .eq('id', documentId) .eq('company_id', companyId) .maybeSingle() if (docError) throw new Error(`Database error: ${docError.message}`) if (!doc) throw new Error('Document not found') const ttlSeconds = 300 const { data: signed, error: signError } = await supabase.storage .from('documents') .createSignedUrl(doc.storage_path, ttlSeconds) if (signError || !signed) { throw new Error(`Failed to create signed URL: ${signError?.message ?? 'unknown error'}`) } const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString() return { document_id: doc.id, file_name: doc.file_name, mime_type: doc.mime_type, size_bytes: doc.file_size_bytes, signed_url: signed.signedUrl, expires_at: expiresAt, } }, }, { name: 'gnubok_attach_document_to_transaction', description: 'Stage attaching a document to a bank transaction. The document is pinned to the tx; when the tx is later categorized the link propagates to the journal entry. Stages for approval.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string', description: 'UUID of the bank transaction' }, document_id: { type: 'string', description: 'UUID of the document_attachments row' }, idempotency_key: { type: 'string', description: 'Optional UUID to dedupe retries' }, dry_run: { type: 'boolean', description: 'Preview without staging' }, }, required: ['transaction_id', 'document_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string const documentId = args.document_id as string if (!transactionId) throw new Error('transaction_id is required') if (!documentId) throw new Error('document_id is required') const { data: tx, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, date, document_id') .eq('id', transactionId) .eq('company_id', companyId) .maybeSingle() if (txError || !tx) throw new Error('Transaction not found') const { data: doc, error: docError } = await supabase .from('document_attachments') .select('id, file_name, mime_type') .eq('id', documentId) .eq('company_id', companyId) .maybeSingle() if (docError || !doc) throw new Error('Document not found') // If the tx already has a different doc pinned, fetch its identity so the // human approver sees "replaces X.pdf with Y.pdf" rather than just a flag. // Required by BFL 5 kap 5 § rättelse (the approver must know what's being // displaced before authorising the change). type ExistingDoc = { id: string; file_name: string; journal_entry_id: string | null } let existingDoc: ExistingDoc | null = null if (tx.document_id && tx.document_id !== documentId) { const { data: prev } = await supabase .from('document_attachments') .select('id, file_name, journal_entry_id') .eq('id', tx.document_id) .eq('company_id', companyId) .maybeSingle() if (prev) { existingDoc = prev as unknown as ExistingDoc } } // Pull the matching invoice_inbox_items extracted_data so the approver // sees vendor/amount/currency/date — the same hints the agent had when // choosing this attachment. Mirrors the BFL 5 kap 6 § informed-rättelse // intent: the human authorising the link should see what's on the doc. let docVendorName: string | null = null let docAmount: number | null = null let docCurrency: string | null = null let docInvoiceDate: string | null = null const { data: inbox } = await supabase .from('invoice_inbox_items') .select('extracted_data') .eq('document_id', documentId) .eq('company_id', companyId) .limit(1) .maybeSingle() if (inbox?.extracted_data) { const ext = inbox.extracted_data as Record const supplier = ext.supplier as Record | undefined const invoice = ext.invoice as Record | undefined const totals = ext.totals as Record | undefined docVendorName = (supplier?.name as string) || null docAmount = (totals?.total as number) || null docCurrency = (invoice?.currency as string) || null docInvoiceDate = (invoice?.invoiceDate as string) || null } return stagePendingOperation( supabase, companyId, userId, 'attach_document_to_transaction', `Koppla bilaga: ${doc.file_name} → ${tx.merchant_name || tx.description || transactionId}`, { transaction_id: transactionId, document_id: documentId }, { transaction_description: tx.merchant_name || tx.description, transaction_amount: tx.amount, transaction_currency: tx.currency, transaction_date: tx.date, document_file_name: doc.file_name, document_mime_type: doc.mime_type, document_vendor_name: docVendorName, document_amount: docAmount, document_currency: docCurrency, document_invoice_date: docInvoiceDate, will_overwrite_existing: existingDoc != null, existing_document_id: existingDoc?.id ?? null, existing_document_file_name: existingDoc?.file_name ?? null, existing_document_is_rakenskapsinformation: existingDoc?.journal_entry_id != null, }, actor, undefined, { idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined, dryRun: args.dry_run === true, } ) }, }, // ── Payroll (Lönehantering) ────────────────────────────────── { name: 'gnubok_list_employees', description: 'List employees for the active company. Personnummer returned masked (YYYYMMDD-XXXX).', inputSchema: { type: 'object', additionalProperties: false, properties: { active_only: { type: 'boolean', description: 'Only active employees (default: true)' }, }, }, outputSchema: { type: 'object', additionalProperties: false, properties: { employees: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, }, required: ['employees', 'count'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const activeOnly = args.active_only !== false let query = supabase .from('employees') .select('id, first_name, last_name, personnummer, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active') .eq('company_id', companyId) if (activeOnly) query = query.eq('is_active', true) const { data, error } = await query.order('last_name') if (error) throw new Error(`Database error: ${error.message}`) const employees = (data || []).map(e => ({ ...e, personnummer: maskPersonnummer(decryptPersonnummer(e.personnummer as string)) })) return { employees, count: employees.length } }, }, { name: 'gnubok_get_salary_run', description: 'Get salary run with status, totals, per-employee breakdown (gross, tax, net, avgifter, vacation accrual) and step-by-step calculation breakdown.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const id = args.salary_run_id as string const { data: run, error } = await supabase .from('salary_runs') .select('*') .eq('id', id) .eq('company_id', companyId) .single() if (error || !run) throw new Error('Salary run not found') const { data: employees } = await supabase .from('salary_run_employees') .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4)') .eq('salary_run_id', id) return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record), personnummer: maskPersonnummer(decryptPersonnummer((e.employee as Record).personnummer as string)) } : null })) } }, }, { name: 'gnubok_get_salary_journal', description: 'Salary journal (lönejournal) for a year: per-employee per-month rows + yearly totals.', inputSchema: { type: 'object', additionalProperties: false, properties: { year: { type: 'number', description: 'Year to report on' }, }, required: ['year'], }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const { generateSalaryJournal } = await import('@/lib/reports/salary-journal') return generateSalaryJournal(supabase, companyId, args.year as number) }, }, { name: 'gnubok_create_salary_run', description: 'Create a draft salary run for a period and add all active employees with base lines. Use gnubok_calculate_salary_run next; final approval/booking happens in the web UI.', 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: { type: 'object' }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase) { const { period_year, period_month, payment_date } = args as { period_year: number; period_month: number; payment_date: string } // Create run const { data: run, error: runError } = await supabase .from('salary_runs') .insert({ company_id: companyId, user_id: userId, period_year, period_month, payment_date }) .select() .single() if (runError) throw new Error(runError.code === '23505' ? 'Salary run already exists for this period' : runError.message) // Add all active employees const { data: employees } = await supabase.from('employees').select('*').eq('company_id', companyId).eq('is_active', true) for (const emp of employees || []) { const baseAmount = emp.salary_type === 'monthly' ? Math.round((emp.monthly_salary || 0) * (emp.employment_degree / 100) * 100) / 100 : 0 const { data: sre } = await supabase.from('salary_run_employees').insert({ salary_run_id: run.id, employee_id: emp.id, company_id: companyId, employment_degree: emp.employment_degree, monthly_salary: emp.monthly_salary || 0, salary_type: emp.salary_type, tax_table_number: emp.tax_table_number, tax_column: emp.tax_column, }).select().single() if (sre) { const { getLineItemAccount } = await import('@/lib/salary/account-mapping') const itemType = emp.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary' await supabase.from('salary_line_items').insert({ salary_run_employee_id: sre.id, company_id: companyId, item_type: itemType, description: emp.salary_type === 'monthly' ? 'Grundlön' : 'Timlön', amount: baseAmount, is_taxable: true, is_avgift_basis: true, is_vacation_basis: true, account_number: getLineItemAccount(itemType as never, emp.employment_type), sort_order: 0, }) } } return { ...run, employee_count: (employees || []).length, message: `Salary run created with ${(employees || []).length} employees. Use the web UI to calculate, review, and book.` } }, }, { name: 'gnubok_calculate_salary_run', description: 'Calculate a draft salary run: tax, avgifter, vacation accrual, totals. Run must be in draft status.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: { type: 'object' }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, userId, supabase) { // Delegate to the calculate API endpoint logic const id = args.salary_run_id as string const { data: run } = await supabase.from('salary_runs').select('*').eq('id', id).eq('company_id', companyId).single() if (!run) throw new Error('Salary run not found') if (run.status !== 'draft') throw new Error('Can only calculate draft runs') // Trigger via internal fetch const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' const res = await fetch(`${appUrl}/api/salary/runs/${id}/calculate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': `gnubok-company-id=${companyId}` }, }) if (!res.ok) throw new Error('Calculation failed — use the web UI to calculate') return { message: 'Calculation complete. Review results in the web UI.', salary_run_id: id } }, }, { name: 'gnubok_generate_agi', description: 'Generate AGI XML (Arbetsgivardeklaration) for a salary run. Run must be past draft. Stored 7 years per BFL; download URL returned.', inputSchema: { type: 'object', additionalProperties: false, properties: { salary_run_id: { type: 'string', description: 'UUID of the salary run' }, }, required: ['salary_run_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { message: { type: 'string' }, period: { type: 'string' }, employee_count: { type: 'number' }, download_url: { type: 'string' }, }, }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const id = args.salary_run_id as string const { data: run } = await supabase.from('salary_runs').select('period_year, period_month, status').eq('id', id).eq('company_id', companyId).single() if (!run) throw new Error('Salary run not found') if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) throw new Error('Run must be past draft status to generate AGI') const { data: emps } = await supabase.from('salary_run_employees').select('id').eq('salary_run_id', id) return { message: `AGI ready for ${run.period_year}-${String(run.period_month).padStart(2, '0')}. Download XML from /api/salary/runs/${id}/agi/xml`, period: `${run.period_year}-${String(run.period_month).padStart(2, '0')}`, employee_count: (emps || []).length, download_url: `/api/salary/runs/${id}/agi/xml`, } }, }, // ── Stream 1 Phase 1: Bookkeeping write (high-risk, always staged) ── { name: 'gnubok_close_period', description: 'Stage period close (irreversible per BFL). Requires period locked + year-end closing entry posted. High-risk — always staged, never auto-committed.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to close' }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: period, error: fetchError } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (fetchError || !period) throw new Error('Fiscal period not found') if (period.is_closed) throw new Error('Period is already closed') if (!period.locked_at) throw new Error('Period must be locked before closing — call gnubok_lock_period first') if (!period.closing_entry_id) throw new Error('Year-end closing entry must exist before the period can be closed') return stagePendingOperation(supabase, companyId, userId, 'close_period', `Stäng period: ${period.name} (${period.period_start} – ${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', description: 'Stage period lock — blocks new entries. Requires zero unbooked business transactions. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to lock' }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: period, error: fetchError } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (fetchError || !period) throw new Error('Fiscal period not found') if (period.is_closed) throw new Error('Period is already closed') if (period.locked_at) throw new Error('Period is already locked') const { count: unbookedCount } = await supabase .from('transactions') .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .is('journal_entry_id', null) .eq('is_business', true) .gte('date', period.period_start) .lte('date', period.period_end) if (unbookedCount && unbookedCount > 0) { throw new Error( `Kan inte låsa period: ${unbookedCount} affärstransaktion(er) saknar bokföring. Bokför alla transaktioner först.` ) } return stagePendingOperation(supabase, companyId, userId, 'lock_period', `Lås period: ${period.name} (${period.period_start} – ${period.period_end})`, { fiscal_period_id: fiscalPeriodId }, { period_name: period.name, period_start: period.period_start, period_end: period.period_end, unbooked_business_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', description: 'Stage uncategorize: reverses linked journal entry via storno (never deletes) and clears the category. Stages for approval.', inputSchema: { type: 'object', additionalProperties: false, properties: { transaction_id: { type: 'string', description: 'UUID of the transaction to uncategorize' }, }, required: ['transaction_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { const transactionId = args.transaction_id as string if (!transactionId) throw new Error('transaction_id is required') const { data: tx, error: txError } = await supabase .from('transactions') .select('id, description, merchant_name, amount, currency, date, category, journal_entry_id') .eq('id', transactionId) .eq('company_id', companyId) .single() if (txError || !tx) throw new Error('Transaction not found') if (!tx.journal_entry_id) throw new Error('Transaction has no journal entry to reverse') const { data: entry } = await supabase .from('journal_entries') .select('id, voucher_number, voucher_series, status') .eq('id', tx.journal_entry_id) .eq('company_id', companyId) .single() if (!entry || entry.status !== 'posted') { throw new Error('Linked journal entry is not posted — nothing to reverse') } return stagePendingOperation(supabase, companyId, userId, 'uncategorize_transaction', `Återta kategorisering: ${tx.merchant_name || tx.description || transactionId}`, { transaction_id: transactionId, journal_entry_id: tx.journal_entry_id }, { transaction_description: tx.merchant_name || tx.description, amount: tx.amount, currency: tx.currency, date: tx.date, current_category: tx.category, will_reverse_voucher: `${entry.voucher_series}${entry.voucher_number}`, method: 'storno (reversal entry, never deletes)', }, actor ) }, }, { name: 'gnubok_export_sie', description: 'Generate SIE-4 file for a fiscal period (standard Swedish bookkeeping interchange format). Returns SIE text content.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to export' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { content: { type: 'string' }, byte_size: { type: 'number' }, fiscal_period_id: { type: 'string' }, company_name: { type: 'string' }, generated_at: { type: 'string' }, }, }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, _userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: company } = await supabase .from('company_settings') .select('company_name, org_number') .eq('company_id', companyId) .single() if (!company) throw new Error('Company settings not found') const sieContent = await generateSIEExport(supabase, companyId, { fiscal_period_id: fiscalPeriodId, company_name: company.company_name || 'Unknown', org_number: company.org_number, }) return { content: sieContent, byte_size: Buffer.byteLength(sieContent, 'utf8'), fiscal_period_id: fiscalPeriodId, company_name: company.company_name, org_number: company.org_number, generated_at: new Date().toISOString(), } }, }, { name: 'gnubok_audit_package', description: "Single-call audit package for a fiscal period: SIE-4 + reports (trial balance, income statement, balance sheet, general ledger, journal register, VAT) + receipts + audit log + voucher gaps, zipped. Returns a 1-hour signed download URL. Long-running on large datasets.", inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to package' }, include_documents: { type: 'boolean', description: 'Include receipts/document binaries in the zip (default true)' }, estimate_only: { type: 'boolean', description: 'Return size estimate without generating (default false)' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { download_url: { type: ['string', 'null'], description: 'Signed Supabase Storage URL valid for 1 hour. Null when estimate_only=true.' }, storage_path: { type: ['string', 'null'] }, file_name: { type: 'string' }, size_bytes: { type: 'number' }, size_limit_bytes: { type: 'number' }, within_limit: { type: 'boolean' }, period: { type: 'object' }, generated_at: { type: 'string' }, expires_at: { type: ['string', 'null'] }, estimate_only: { type: 'boolean' }, }, required: ['file_name', 'size_bytes', 'period', 'generated_at', 'estimate_only'], }, annotations: { readOnlyHint: false, // produces a Storage artifact destructiveHint: false, idempotentHint: true, // repeat calls produce equivalent archives, fresh URL openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const includeDocuments = args.include_documents !== false const estimateOnly = args.estimate_only === true const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 // Verify period belongs to the company const { data: period, error: periodErr } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (periodErr || !period) throw new Error('Fiscal period not found') const generatedAt = new Date().toISOString() // Pre-flight size estimate — also serves the estimate-only path const estimate = await estimateArchiveSize(supabase, companyId, 'period', fiscalPeriodId) const sizeBytes = estimate.total_bytes const withinLimit = sizeBytes <= SIZE_LIMIT_BYTES const fileName = `arkiv_${period.name.replace(/[^\w-]/g, '_')}_${fiscalPeriodId.slice(0, 8)}.zip` if (estimateOnly) { return { download_url: null, storage_path: null, file_name: fileName, size_bytes: sizeBytes, size_limit_bytes: SIZE_LIMIT_BYTES, within_limit: withinLimit, period: { id: period.id, name: period.name, period_start: period.period_start, period_end: period.period_end, }, generated_at: generatedAt, expires_at: null, estimate_only: true, } } if (includeDocuments && !withinLimit) { throw new Error( `Archive would exceed ${Math.round(SIZE_LIMIT_BYTES / 1024 / 1024)} MB (estimate: ${Math.round(sizeBytes / 1024 / 1024)} MB). Retry with include_documents=false to omit receipt binaries.` ) } // Generate the archive (long-running) const zipBuffer = await generateFullArchive(supabase, companyId, { scope: 'period', period_id: fiscalPeriodId, include_documents: includeDocuments, }) // Upload to Storage under a per-user audit-packages folder const storagePath = `${userId}/audit-packages/${Date.now()}_${fileName}` const { error: uploadErr } = await supabase.storage .from('documents') .upload(storagePath, new Uint8Array(zipBuffer), { contentType: 'application/zip', upsert: false, }) if (uploadErr) throw new Error(`Failed to upload archive: ${uploadErr.message}`) // Sign for 1 hour const SIGNED_URL_TTL_SECONDS = 3600 const { data: signed, error: signErr } = await supabase.storage .from('documents') .createSignedUrl(storagePath, SIGNED_URL_TTL_SECONDS) if (signErr || !signed) { // Best-effort cleanup of the uploaded blob if signing failed await supabase.storage.from('documents').remove([storagePath]) throw new Error(`Failed to sign archive URL: ${signErr?.message ?? 'unknown error'}`) } const expiresAt = new Date(Date.now() + SIGNED_URL_TTL_SECONDS * 1000).toISOString() return { download_url: signed.signedUrl, storage_path: storagePath, file_name: fileName, size_bytes: zipBuffer.byteLength, size_limit_bytes: SIZE_LIMIT_BYTES, within_limit: true, period: { id: period.id, name: period.name, period_start: period.period_start, period_end: period.period_end, }, generated_at: generatedAt, expires_at: expiresAt, estimate_only: false, } }, }, // ── Stream 1 Phase 1 follow-up: year-end, opening balances, revaluation, // voucher gaps, supplier-invoice lifecycle, proforma conversion ── { name: 'gnubok_year_end_readiness', description: "Pre-flight before irreversible gnubok_run_year_end. Returns ready (bool) + ordered blockers (drafts, voucher gaps, sequence mismatches, unbalanced trial balance, FX revaluation needed) + warnings + optional preview of the closing entry. Use this before staging year-end.", inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to year-end' }, include_preview: { type: 'boolean', description: 'If true, also return the would-be closing journal entry preview (default false)' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { period: { type: 'object' }, ready: { type: 'boolean' }, blockers: { type: 'array', items: { type: 'object' } }, warnings: { type: 'array', items: { type: 'string' } }, draft_count: { type: 'number' }, unexplained_voucher_gap_count: { type: 'number' }, sequence_mismatch_count: { type: 'number' }, trial_balance_balanced: { type: 'boolean' }, preview: { type: ['object', 'null'] }, summary: { type: 'string' }, }, required: ['ready', 'blockers', 'warnings', 'summary'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, async execute(args, companyId, userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string const includePreview = args.include_preview === true if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') // Fetch period for context (the validate function returns errors if not found, // but agents benefit from period metadata in the response) const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id, continuity_verified') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (!period) throw new Error('Fiscal period not found') const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId) // Reshape error strings into structured blockers so the agent (and any // dashboard) can render and act on each one independently. The lib // returns flat strings; we tag each with a `kind` heuristic for routing. const blockers = validation.errors.map((message) => { let kind: string = 'other' if (/draft journal entries/i.test(message)) kind = 'draft_entries' else if (/voucher gap/i.test(message)) kind = 'unexplained_voucher_gap' else if (/Sequence counter integrity/i.test(message)) kind = 'sequence_mismatch' else if (/Trial balance is not balanced/i.test(message)) kind = 'trial_balance_unbalanced' else if (/already closed/i.test(message)) kind = 'period_already_closed' else if (/has not yet ended/i.test(message)) kind = 'period_not_ended' else if (/closing entry already exists/i.test(message)) kind = 'closing_entry_exists' else if (/continuity check failed/i.test(message)) kind = 'opening_balance_continuity' else if (/Fiscal period not found/i.test(message)) kind = 'period_not_found' return { kind, severity: 'high' as const, message } }) let preview = null if (includePreview && validation.ready) { try { preview = await previewYearEndClosing(supabase, companyId, userId, fiscalPeriodId) } catch (err) { // Preview is opportunistic — never fail the readiness check on it. preview = { error: err instanceof Error ? err.message : 'Preview unavailable' } } } const summary = validation.ready ? validation.warnings.length > 0 ? `Klart för bokslut. ${validation.warnings.length} varning(ar) att granska.` : 'Klart för bokslut.' : `Inte klart: ${blockers.length} blockerare måste åtgärdas.` return { period: { id: period.id, name: period.name, period_start: period.period_start, period_end: period.period_end, is_closed: period.is_closed, locked_at: period.locked_at, closing_entry_id: period.closing_entry_id, continuity_verified: period.continuity_verified, }, ready: validation.ready, blockers, warnings: validation.warnings, draft_count: validation.draftCount, unexplained_voucher_gap_count: validation.unexplainedGaps.length, sequence_mismatch_count: validation.sequenceMismatches.length, trial_balance_balanced: validation.trialBalanceBalanced, preview, summary, } }, }, { name: 'gnubok_run_year_end', description: 'Stage year-end closing: zero result accounts (class 3–8) into 2099, lock period, create next period, seed opening balances. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to close out' }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: period } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at') .eq('id', fiscalPeriodId).eq('company_id', companyId).single() if (!period) throw new Error('Fiscal period not found') if (period.is_closed) throw new Error('Period is already closed') return stagePendingOperation(supabase, companyId, userId, 'run_year_end', `Bokslut: ${period.name}`, { fiscal_period_id: fiscalPeriodId }, { period_name: period.name, period_start: period.period_start, period_end: period.period_end, will: 'zero result accounts into 2099, lock period, create next period, generate opening balances', }, actor, { description: 'After year-end, the period is locked and ready for closing via gnubok_close_period.', tool: 'gnubok_close_period', args: { fiscal_period_id: fiscalPeriodId }, } ) }, }, { name: 'gnubok_set_opening_balances', description: 'Stage opening-balance entry: copy class 1–2 closing balances from a closed period into the next period.', inputSchema: { type: 'object', additionalProperties: false, properties: { closed_period_id: { type: 'string', description: 'UUID of the closed source period' }, next_period_id: { type: 'string', description: 'UUID of the next (target) period' }, }, required: ['closed_period_id', 'next_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const closedId = args.closed_period_id as string const nextId = args.next_period_id as string if (!closedId || !nextId) throw new Error('closed_period_id and next_period_id are required') return stagePendingOperation(supabase, companyId, userId, 'set_opening_balances', `Ingående balans: ${nextId}`, { closed_period_id: closedId, next_period_id: nextId }, { closed_period_id: closedId, next_period_id: nextId, will: 'create opening balance entry from closed-period trial balance' }, actor ) }, }, { name: 'gnubok_run_currency_revaluation', description: 'Stage currency revaluation: revalue open FX receivables/payables to closing-date rate (posts 3960/7960). One per period max.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, closing_date: { type: 'string', description: 'Revaluation date (YYYY-MM-DD)' }, }, required: ['fiscal_period_id', 'closing_date'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string const closingDate = args.closing_date as string if (!fiscalPeriodId || !closingDate) throw new Error('fiscal_period_id and closing_date are required') return stagePendingOperation(supabase, companyId, userId, 'run_currency_revaluation', `Valutaomvärdering ${closingDate}`, { fiscal_period_id: fiscalPeriodId, closing_date: closingDate }, { fiscal_period_id: fiscalPeriodId, closing_date: closingDate, posts_to: ['3960', '7960'] }, actor ) }, }, { name: 'gnubok_list_voucher_gaps', description: 'List voucher number gaps in a fiscal period (BFNAR 2013:2 audit requirement). Each gap shows whether it has an explanation.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string' }, voucher_series: { type: 'string', description: 'Optional series filter (e.g. "A")' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: false, properties: { gaps: { type: 'array', items: { type: 'object' } }, total_gaps: { type: 'number' }, unexplained_gaps: { type: 'number' }, }, required: ['gaps', 'total_gaps', 'unexplained_gaps'], }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase) { const fiscalPeriodId = args.fiscal_period_id as string const voucherSeries = args.voucher_series as string | undefined let seriesQuery = supabase .from('voucher_sequences').select('voucher_series') .eq('company_id', companyId).eq('fiscal_period_id', fiscalPeriodId) if (voucherSeries) seriesQuery = seriesQuery.eq('voucher_series', voucherSeries) const { data: seriesRows } = await seriesQuery if (!seriesRows || seriesRows.length === 0) { return { gaps: [], total_gaps: 0, unexplained_gaps: 0 } } const allGaps: Array<{ series: string; gap_start: number; gap_end: number; explanation: unknown }> = [] for (const row of seriesRows) { const { data: gaps } = await supabase.rpc('detect_voucher_gaps', { p_company_id: companyId, p_fiscal_period_id: fiscalPeriodId, p_series: row.voucher_series, }) if (gaps) { for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) { allGaps.push({ series: row.voucher_series, gap_start: gap.gap_start, gap_end: gap.gap_end, explanation: null }) } } } if (allGaps.length > 0) { const { data: explanations } = await supabase .from('voucher_gap_explanations') .select('id, voucher_series, gap_start, gap_end, explanation, created_at') .eq('company_id', companyId).eq('fiscal_period_id', fiscalPeriodId) if (explanations) { const map = new Map(explanations.map((e) => [`${e.voucher_series}:${e.gap_start}:${e.gap_end}`, e])) for (const g of allGaps) { g.explanation = map.get(`${g.series}:${g.gap_start}:${g.gap_end}`) ?? null } } } return { gaps: allGaps, total_gaps: allGaps.length, unexplained_gaps: allGaps.filter((g) => !g.explanation).length, } }, }, { name: 'gnubok_explain_voucher_gap', description: 'Stage explanation for a voucher gap (BFNAR 2013:2 compliance — every gap needs a documented reason).', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string' }, voucher_series: { type: 'string' }, gap_start: { type: 'number' }, gap_end: { type: 'number' }, explanation: { type: 'string', description: 'Swedish prose: why the gap exists' }, }, required: ['fiscal_period_id', 'voucher_series', 'gap_start', 'gap_end', 'explanation'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const explanation = args.explanation as string if (!explanation?.trim()) throw new Error('explanation is required') return stagePendingOperation(supabase, companyId, userId, 'explain_voucher_gap', `Förklara verifikationslucka ${args.voucher_series}:${args.gap_start}-${args.gap_end}`, { fiscal_period_id: args.fiscal_period_id, voucher_series: args.voucher_series, gap_start: args.gap_start, gap_end: args.gap_end, explanation: explanation.trim(), }, { voucher_series: args.voucher_series, gap_start: args.gap_start, gap_end: args.gap_end, explanation: explanation.trim(), }, actor ) }, }, { name: 'gnubok_approve_supplier_invoice', description: 'Stage approval of a registered supplier invoice (registered → approved). High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { supplier_invoice_id: { type: 'string' } }, required: ['supplier_invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.supplier_invoice_id as string if (!id) throw new Error('supplier_invoice_id is required') const { data: inv } = await supabase .from('supplier_invoices') .select('id, supplier_invoice_number, invoice_date, total, currency, status, supplier:suppliers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Supplier invoice not found') if (inv.status !== 'registered') throw new Error('Kan bara godkänna registrerade fakturor') 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, undefined, inv.invoice_date ? { dateForPeriodCheck: inv.invoice_date } : {}, ) }, }, { name: 'gnubok_credit_supplier_invoice', description: 'Stage credit-note (kreditfaktura) for a supplier invoice: mirror invoice with negative effect + reverses registration JE (accrual).', inputSchema: { type: 'object', additionalProperties: false, properties: { supplier_invoice_id: { type: 'string' } }, required: ['supplier_invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.supplier_invoice_id as string if (!id) throw new Error('supplier_invoice_id is required') const { data: inv } = await supabase .from('supplier_invoices') .select('id, supplier_invoice_number, total, currency, status, supplier:suppliers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Supplier invoice not found') if (inv.status === 'credited') throw new Error('Fakturan har redan krediterats') return stagePendingOperation(supabase, companyId, userId, 'credit_supplier_invoice', `Kreditera leverantörsfaktura ${inv.supplier_invoice_number}`, { supplier_invoice_id: id }, { supplier_invoice_number: inv.supplier_invoice_number, supplier_name: (inv.supplier as { name?: string } | null)?.name, total: inv.total, currency: inv.currency, method: 'creates KREDIT- mirror invoice + reverses registration JE (accrual)', }, actor ) }, }, { name: 'gnubok_convert_invoice', description: 'Stage conversion of a proforma invoice to a real invoice. Allocates F-series number, copies items, marks proforma cancelled.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string' } }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.invoice_id as string if (!id) throw new Error('invoice_id is required') const { data: inv } = await supabase .from('invoices') .select('id, document_type, status, total, currency, customer:customers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Invoice not found') if (inv.document_type !== 'proforma') throw new Error('Endast proformafakturor kan konverteras') if (inv.status === 'cancelled') throw new Error('Denna proformafaktura har redan makuleras') return stagePendingOperation(supabase, companyId, userId, 'convert_invoice', `Konvertera proforma → faktura`, { invoice_id: id }, { customer_name: (inv.customer as { name?: string } | null)?.name, total: inv.total, currency: inv.currency, will: 'allocate F-series number, copy items, cancel proforma', }, actor, { description: 'After conversion, send the new invoice with gnubok_send_invoice.', tool: 'gnubok_send_invoice', } ) }, }, { name: 'gnubok_unlock_period', description: 'Stage period unlock — clears locked_at so entries can be posted again. Cannot unlock a closed period. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to unlock' }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { data: period, error: fetchError } = await supabase .from('fiscal_periods') .select('id, name, period_start, period_end, is_closed, locked_at') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .single() if (fetchError || !period) throw new Error('Fiscal period not found') if (period.is_closed) throw new Error('Cannot unlock a closed period') if (!period.locked_at) throw new Error('Period is not locked') return stagePendingOperation(supabase, companyId, userId, 'unlock_period', `Lås upp period: ${period.name} (${period.period_start} – ${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 ) }, }, { name: 'gnubok_credit_invoice', description: 'Stage credit note (kreditfaktura) for a customer invoice: KR- prefixed mirror invoice + reverses original JE (accrual). Original must be sent/paid/overdue and not already credited.', inputSchema: { type: 'object', additionalProperties: false, properties: { invoice_id: { type: 'string', description: 'UUID of the invoice to credit' }, reason: { type: 'string', description: 'Optional reason note (Swedish, shown on the credit note)' }, }, required: ['invoice_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const id = args.invoice_id as string const reason = args.reason as string | undefined if (!id) throw new Error('invoice_id is required') const { data: inv } = await supabase .from('invoices') .select('id, invoice_number, document_type, status, total, currency, customer:customers(name)') .eq('id', id).eq('company_id', companyId).single() if (!inv) throw new Error('Invoice not found') if (inv.document_type && inv.document_type !== 'invoice') { throw new Error('Credit notes can only be created from standard invoices') } if (inv.status === 'credited') throw new Error('Fakturan har redan krediterats') if (!['sent', 'paid', 'overdue'].includes(inv.status)) { throw new Error('Endast skickade, betalda eller förfallna fakturor kan krediteras') } return stagePendingOperation(supabase, companyId, userId, 'credit_invoice', `Kreditera faktura ${inv.invoice_number}`, { invoice_id: id, reason }, { invoice_number: inv.invoice_number, customer_name: (inv.customer as { name?: string } | null)?.name, total: inv.total, currency: inv.currency, reason: reason || null, method: 'creates KR- mirror invoice + reverses original JE (accrual)', }, actor ) }, }, { name: 'gnubok_import_sie', description: 'Stage SIE-file import (types 1–4, CP437/UTF-8/Latin-1). On commit creates fiscal period, opening balances, and journal entries. High-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { file_content: { type: 'string', description: 'Full SIE file contents' }, filename: { type: 'string', description: 'Original filename' }, mappings: { type: 'array', description: 'Account mappings: { sourceAccount, sourceName, targetAccount, targetName, confidence, matchType, isOverride }', items: { type: 'object' }, }, create_fiscal_period: { type: 'boolean' }, import_opening_balances: { type: 'boolean' }, import_transactions: { type: 'boolean' }, voucher_series: { type: 'string', description: 'Override voucher series for imported vouchers' }, }, required: ['file_content', 'filename', 'mappings'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fileContent = args.file_content as string const filename = args.filename as string const mappings = args.mappings as unknown[] | undefined if (!fileContent || !filename || !Array.isArray(mappings)) { throw new Error('file_content, filename, and mappings are required') } 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, }, { filename, file_size_bytes: fileContent.length, mappings_count: mappings.length, create_fiscal_period: Boolean(args.create_fiscal_period), import_opening_balances: Boolean(args.import_opening_balances), import_transactions: Boolean(args.import_transactions), will: 'parse SIE on commit, create fiscal period + opening balances + journal entries', }, actor ) }, }, // ── Phase 4: arbitrary-line bookkeeping primitives ─────────────── { name: 'gnubok_create_voucher', description: 'Stage a manual verifikation with arbitrary balanced lines. Use for capitalization (e.g. 1010), period-end accruals, FX adjustments, and rättelseposter outside categorize_transaction. HIGH risk — always staged, never auto-committed.', 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.' }, 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.' }, cost_center: { type: 'string' }, project: { type: 'string' }, }, required: ['account_number'], }, }, }, required: ['entry_date', 'description', 'lines'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const entryDate = args.entry_date as string const description = args.description as string const rawLines = args.lines as Array> | undefined if (!entryDate || !description || !Array.isArray(rawLines) || rawLines.length < 2) { throw new Error('entry_date, description, and at least two lines are required') } // Normalize so validateBalance + preview see consistent numeric types. const lines = rawLines.map((l) => ({ 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, 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 fiscal period. Two paths: // 1. Caller supplied fiscal_period_id → verify it exists and is open. // 2. Omitted → look up the open period covering entry_date. // Both paths converge on a Swedish-language error if no valid open // period is available. (NOTE: the executor re-checks period_lock at // commit time — this staging gate is advisory and exists for UX, the // commit-time guard is the authoritative one. Don't remove it as // "redundant".) let fiscalPeriodId = (args.fiscal_period_id as string | undefined) ?? null if (fiscalPeriodId) { const { data: period, error: periodErr } = await supabase .from('fiscal_periods') .select('id, is_closed, period_start, period_end, name') .eq('id', fiscalPeriodId) .eq('company_id', companyId) .maybeSingle() if (periodErr || !period) { throw new Error(`Fiscal period ${fiscalPeriodId} not found for this company.`) } if (period.is_closed) { throw new Error( `Räkenskapsperioden "${period.name ?? fiscalPeriodId}" är låst. ` + 'Lås upp perioden, eller välj en öppen period.' ) } // Defense in depth: also verify the supplied period actually covers // entry_date so the engine's EntryDateOutsideFiscalPeriodError surfaces // as a Swedish message rather than a generic engine error. if (entryDate < period.period_start || entryDate > period.period_end) { throw new Error( `Datumet ${entryDate} ligger utanför "${period.name ?? 'perioden'}" (${period.period_start}–${period.period_end}).` ) } } else { fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate) } if (!fiscalPeriodId) { throw new Error(`No open fiscal period covers ${entryDate}. Open a period or pick a different date.`) } // Resolve account names for the preview so the approver reads // "1010 Balanserade utgifter / 2440 Leverantörsskulder" rather than // bare numbers. Also gate: refuse to stage when any line references an // unknown or inactive account so the approver isn't shown a voucher // that would fail at commit time anyway. const accountNumbers = [...new Set(lines.map((l) => l.account_number))] const { data: accounts } = await supabase .from('chart_of_accounts') .select('account_number, account_name, is_active') .eq('company_id', companyId) .in('account_number', accountNumbers) const accountInfo = new Map() for (const a of accounts || []) { accountInfo.set(a.account_number as string, { name: (a.account_name as string) ?? '', active: Boolean(a.is_active), }) } const unknownAccounts = accountNumbers.filter((n) => !accountInfo.has(n)) const inactiveAccounts = accountNumbers.filter( (n) => accountInfo.has(n) && !accountInfo.get(n)!.active, ) if (unknownAccounts.length > 0 || inactiveAccounts.length > 0) { const parts: string[] = [] if (unknownAccounts.length > 0) { parts.push(`saknas i kontoplanen: ${unknownAccounts.join(', ')}`) } if (inactiveAccounts.length > 0) { parts.push(`inaktiva: ${inactiveAccounts.join(', ')}`) } throw new Error( `Kan inte skapa verifikation. Konton ${parts.join('; ')}. ` + 'Aktivera dem i kontoplanen eller välj andra konton.' ) } const previewLines = lines.map((l) => ({ account_number: l.account_number, account_name: accountInfo.get(l.account_number)?.name ?? null, debit_amount: l.debit_amount, credit_amount: l.credit_amount, line_description: l.line_description ?? null, })) // NOTE: source_type is intentionally NOT included in the staged params. // The executor hardcodes 'manual' so a tampered or future direct-staged // pending_operations row can't misrepresent the entry's origin. return stagePendingOperation(supabase, companyId, userId, 'create_voucher', `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, 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, will: 'create a posted journal entry with a fresh sequential voucher number', }, actor, undefined, { dateForPeriodCheck: entryDate }, ) }, }, { name: 'gnubok_correct_entry', description: 'Stage a rättelse for a posted verifikation per BFL 5 kap 5§ — storno + new corrected entry in the original period (never in-place edit). Use for partial fixes like 2641 → 2614/2645 while preserving the expense leg. Account drives momsdeklaration ruta, not tax_code. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, properties: { entry_id: { type: 'string', description: 'UUID of the posted journal entry to correct' }, 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.' }, cost_center: { type: 'string' }, project: { type: 'string' }, }, required: ['account_number'], }, }, }, required: ['entry_id', 'lines'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const entryId = args.entry_id as string const rawLines = args.lines as Array> | undefined if (!entryId || !Array.isArray(rawLines) || rawLines.length < 2) { throw new Error('entry_id and at least two lines are required') } const lines = rawLines.map((l) => ({ 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, 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.' ) } // Pre-flight: the executor checks again, but failing fast here gives the // agent a clearer error message than waiting until commit-time. // The Supabase types don't infer through `fiscal_periods!inner(...)`, // so we type the row shape manually rather than fight the generics. type OriginalRow = { id: string status: string entry_date: string description: string voucher_number: number voucher_series: string fiscal_period_id: string fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null lines: Array<{ account_number: string debit_amount: number | string credit_amount: number | string line_description: string | null }> | null } const { data, error: origErr } = await supabase .from('journal_entries') .select( 'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' + 'fiscal_periods!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 || !original) throw new Error('Journal entry not found') if (original.status !== 'posted') { throw new Error(`Only posted entries can be corrected. Current status: ${original.status}.`) } const periodInfo = Array.isArray(original.fiscal_periods) ? original.fiscal_periods[0] : original.fiscal_periods if (periodInfo?.is_closed || periodInfo?.locked_at) { throw new Error( `Fiscal period "${periodInfo.name ?? 'okänd'}" is locked or closed. Unlock the period, or use omprövning for already-filed VAT.` ) } const originalLines = original.lines || [] return stagePendingOperation(supabase, companyId, userId, 'correct_entry', `Rättelse: V${original.voucher_series}${original.voucher_number} — ${original.description}`, { entry_id: entryId, lines, }, { original: { entry_id: entryId, voucher: `${original.voucher_series}${original.voucher_number}`, entry_date: original.entry_date, description: original.description, lines: originalLines.map((l) => ({ account_number: l.account_number, debit_amount: Number(l.debit_amount), credit_amount: Number(l.credit_amount), line_description: l.line_description, })), }, 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, })), }, 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, undefined, { dateForPeriodCheck: original.entry_date }, ) }, }, { name: 'gnubok_reverse_journal_entry', description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Use only when the affärshändelse should never have been booked (duplicate, ghost, test). If booked wrong, use gnubok_correct_entry; for refunds, gnubok_credit_invoice. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, properties: { entry_id: { type: 'string', description: 'UUID of the posted journal entry to reverse' }, reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to today (Swedish timezone). Period attribution always follows the original entry, regardless of this date.' }, reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason — shown in pending_operations review. Not stored on the storno itself. Max 500 chars.' }, }, required: ['entry_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const entryId = args.entry_id as string const reversalDate = typeof args.reversal_date === 'string' ? args.reversal_date : undefined const reason = typeof args.reason === 'string' ? args.reason : undefined if (!entryId) { throw new Error('entry_id is required') } // Belt-and-braces runtime check: inputSchema declares the pattern, but the // MCP dispatcher does not always enforce it — validate again here so a // malformed date never reaches the pending_operations payload. if (reversalDate !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(reversalDate)) { throw new Error('reversal_date must be ISO yyyy-MM-dd') } if (reason !== undefined && reason.length > 500) { throw new Error('reason must be 500 characters or fewer') } // Pre-flight mirrors commitReverseEntry: posted + period not closed/locked. // Failing fast gives a clearer Swedish error than waiting until commit-time. // Both is_closed and locked_at are checked so the staging-time signal // matches the commit-time gate; without locked_at, an agent could see // staged:true with period_status:locked and only discover the rejection // at commit time. type OriginalRow = { id: string status: string entry_date: string description: string voucher_number: number voucher_series: string fiscal_period_id: string fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null lines: Array<{ account_number: string debit_amount: number | string credit_amount: number | string line_description: string | null }> | null } const { data, error: origErr } = await supabase .from('journal_entries') .select( 'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' + 'fiscal_periods!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 || !original) throw new Error('Journal entry not found') if (original.status !== 'posted') { throw new Error(`Only posted entries can be reversed. Current status: ${original.status}.`) } const periodInfo = Array.isArray(original.fiscal_periods) ? original.fiscal_periods[0] : original.fiscal_periods if (periodInfo?.is_closed || periodInfo?.locked_at) { throw new Error( `Fiscal period "${periodInfo.name ?? 'okänd'}" is locked or closed. Unlock the period, or use omprövning for already-filed VAT.` ) } const originalLines = original.lines || [] const reversedPreviewLines = originalLines.map((l) => ({ account_number: l.account_number, debit_amount: Number(l.credit_amount), credit_amount: Number(l.debit_amount), line_description: `Reversal: ${l.line_description ?? ''}`, })) // If the original touches output/input VAT accounts (2610–2670), a storno // is correct ONLY if the moms period covering entry_date has not yet been // filed with Skatteverket. For filed periods the legal path is an // omprövning (rättelse-omprövning per ML 2023:200, SFL 22 kap). gnubok // doesn't track per-VAT-period filing status today, so we surface a // soft warning rather than block — the human approver decides. const vatAccounts = originalLines .map((l) => l.account_number) .filter((acc) => /^26[1-7]\d$/.test(acc)) const vatWarning = vatAccounts.length > 0 ? `Original innehåller momskonton (${[...new Set(vatAccounts)].join(', ')}). Om momsperioden är inlämnad till Skatteverket krävs omprövning (ML 2023:200) — storno räcker inte. Bekräfta att perioden inte är inlämnad innan godkännande.` : null return stagePendingOperation(supabase, companyId, userId, 'reverse_entry', `Makulering: V${original.voucher_series}${original.voucher_number} — ${original.description}`, { entry_id: entryId, reversal_date: reversalDate, }, { original: { entry_id: entryId, voucher: `${original.voucher_series}${original.voucher_number}`, entry_date: original.entry_date, description: original.description, lines: originalLines.map((l) => ({ account_number: l.account_number, debit_amount: Number(l.debit_amount), credit_amount: Number(l.credit_amount), line_description: l.line_description, })), }, reversal: { entry_date: reversalDate ?? null, fiscal_period_id: original.fiscal_period_id, line_count: reversedPreviewLines.length, lines: reversedPreviewLines, }, reason: reason ?? null, ...(vatWarning ? { warnings: [vatWarning] } : {}), will: 'post a storno that mirrors the original with debits and credits swapped, link via reverses_id, and leave the original visible (BFL 5 kap, makulering)', }, actor, undefined, { dateForPeriodCheck: original.entry_date }, ) }, }, // ─── Phase 4-7: bokslut wizard surfaces exposed to agents ─────────── { name: 'gnubok_propose_dispositioner', description: 'Read-only proposal of bokslutsdispositioner for a fiscal period: periodiseringsfond (avsättning + obligatorisk återföring), överavskrivningar, SLP, bolagsskatt. Call before staging postings.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, // Output is the same DispositionsProposal shape returned by GET // /bokslutsdispositioner — surface as a permissive object so the // strict-schema test passes without duplicating the type tree here. outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { buildDispositionsProposal } = await import('@/lib/bokslut/dispositions-proposal-builder') return buildDispositionsProposal(supabase, companyId, fiscalPeriodId) }, }, { name: 'gnubok_propose_accruals', description: 'Read-only proposal of periodiseringar (förutbetalda/upplupna kostnader). Currently surfaces the vacation-liability change; manual prepaid/accrued entries are submitted by the UI form.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { buildAccrualsProposal } = await import('@/lib/bokslut/accruals/accrual-detector') return buildAccrualsProposal(supabase, companyId, fiscalPeriodId) }, }, { name: 'gnubok_propose_annual_depreciation', description: 'Read-only per-asset planenlig avskrivning proposal for a fiscal period. Reads the asset register and existing depreciation schedules. Call before staging the post.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { proposeAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine') return proposeAnnualPostings(supabase, companyId, fiscalPeriodId) }, }, { name: 'gnubok_post_annual_depreciation', description: 'Stage planenlig avskrivning posts — one journal entry per asset for independent reversibility. Mid-risk, always staged.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, asset_ids: { type: 'array', items: { type: 'string' }, description: 'Optional whitelist of asset UUIDs to post; omit to post all proposed.', }, }, required: ['fiscal_period_id'], }, outputSchema: STAGED_OPERATION_SCHEMA, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async execute(args, companyId, userId, supabase, actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined // Mirror the HTTP route's `requireWrite: true` guard so a viewer-role // member can't post depreciation through the MCP surface. RLS would // reject the underlying INSERTs anyway, but failing fast here // produces a much cleaner error than the cascaded RLS rejection. const { data: membership } = await supabase .from('company_members') .select('role') .eq('company_id', companyId) .eq('user_id', userId) .maybeSingle() if (!membership || membership.role === 'viewer') { throw new Error('Write permission required') } 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) return stagePendingOperation( supabase, companyId, userId, 'post_annual_depreciation', `Planenlig avskrivning: ${period.name}`, { fiscal_period_id: fiscalPeriodId, asset_ids: assetIds }, { period_name: period.name, item_count: pending.length, total_amount: pending.reduce((s, i) => s + i.amount, 0), 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, undefined, { dateForPeriodCheck: period.period_end }, ) }, }, { name: 'gnubok_preview_arsredovisning', description: 'Read-only K2 årsredovisning preview for a fiscal period. Returns flerårsöversikt, eget-kapital-förändring, RR, BR, K2 noter, signature slots. PDF download is via UI.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { buildArsredovisningData } = await import('@/lib/bokslut/arsredovisning/build-data') return buildArsredovisningData(supabase, companyId, fiscalPeriodId) }, }, { name: 'gnubok_preview_ef_declaration', description: 'Read-only EF declaration preview: egenavgifter schablonavdrag, räntefördelning, periodiseringsfond, expansionsfond. All declaration-only, never booked. Pass kapitalunderlag and prior-year amounts as inputs.', inputSchema: { type: 'object', additionalProperties: false, properties: { fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' }, category: { type: 'string', enum: ['full', 'pensioner', 'passive'], description: 'Egenavgifter category — defaults to "full"', }, kapitalunderlag: { type: 'number', description: 'Justerat eget kapital vid föregående års utgång (default 0)' }, prior_year_schablonavdrag: { type: 'number' }, prior_year_actual_charged: { type: 'number' }, pfond_desired_amount: { type: 'number' }, expansionsfond_existing_balance: { type: 'number' }, expansionsfond_desired_change: { type: 'number' }, }, required: ['fiscal_period_id'], }, outputSchema: { type: 'object', additionalProperties: true }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async execute(args, companyId, _userId, supabase, _actor) { const fiscalPeriodId = args.fiscal_period_id as string if (!fiscalPeriodId) throw new Error('fiscal_period_id is required') const { computeEfDeclarationPreview } = await import('@/lib/bokslut/enskild-firma/ef-declaration-preview') return computeEfDeclarationPreview(supabase, companyId, fiscalPeriodId, { category: args.category as 'full' | 'pensioner' | 'passive' | undefined, kapitalunderlag: args.kapitalunderlag as number | undefined, priorYearSchablonavdrag: args.prior_year_schablonavdrag as number | undefined, priorYearActualCharged: args.prior_year_actual_charged as number | undefined, pfondDesiredAmount: args.pfond_desired_amount as number | undefined, expansionsfondExistingBalance: args.expansionsfond_existing_balance as number | undefined, expansionsfondDesiredChange: args.expansionsfond_desired_change as number | undefined, }) }, }, ] // ── MCP Protocol Handler ───────────────────────────────────── const SERVER_INFO = { name: 'gnubok', version: '1.0.0', } const PROTOCOL_VERSION = '2025-06-18' 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 } } } /** * 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' | 'unknown_tool' | null requestId: string | number | null userId: string companyId: string }): void { void 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, requestId: payload.requestId, userId: payload.userId, companyId: payload.companyId, }, }) .catch((err) => { // Last-resort guard. EventBus.emit already swallows handler failures, // but if the bus itself is in a bad state we still don't want to break tools. console.error('[mcp] tool_called telemetry emit failed:', err) }) } /** Fire-and-forget telemetry for a tools/list call. */ function emitToolsListTelemetry(payload: { toolCount: number actor: ActorContext latencyMs: number requestId: string | number | null userId: string companyId: string }): void { void eventBus .emit({ type: 'mcp.tools_list_called', payload: { toolCount: payload.toolCount, actorType: payload.actor.type, actorId: payload.actor.id ?? null, actorLabel: payload.actor.label ?? null, latencyMs: payload.latencyMs, requestId: payload.requestId, userId: payload.userId, companyId: payload.companyId, }, }) .catch((err) => { console.error('[mcp] tools_list_called telemetry emit failed:', err) }) } /** Fire-and-forget telemetry for a resources/read call. */ function emitResourceReadTelemetry(payload: { uri: string kind: 'widget' | 'skill' | 'data' | 'unknown' success: boolean errorCode: string | null actor: ActorContext latencyMs: number requestId: string | number | null userId: string companyId: string }): void { void eventBus .emit({ type: 'mcp.resource_read', payload: { uri: payload.uri, kind: payload.kind, success: payload.success, errorCode: payload.errorCode, latencyMs: payload.latencyMs, actorType: payload.actor.type, actorId: payload.actor.id ?? null, actorLabel: payload.actor.label ?? null, requestId: payload.requestId, userId: payload.userId, companyId: payload.companyId, }, }) .catch((err) => { console.error('[mcp] resource_read telemetry 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 appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' const wwwAuth = `Bearer resource_metadata="${appUrl}/.well-known/oauth-protected-resource"` // ── Pre-auth: handle fire-and-forget notifications before auth check ── // MCP notifications have no id and don't expect error responses. // Checking auth on them would return 401 which confuses clients. const clonedRequest = request.clone() try { const peek = await clonedRequest.json() if (peek.method === 'notifications/initialized') { return new Response(null, { status: 202 }) } } catch { // Not valid JSON — fall through to auth + parse below } // ── Auth ── const token = extractBearerToken(request) if (!token) { return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': wwwAuth }, }) } const authResult = await validateApiKey(token) if ('error' in authResult) { const status = authResult.status if (status === 429) { return new Response(authResult.error, { status: 429, headers: { 'Content-Type': 'text/plain', 'Retry-After': '60' }, }) } return new Response('Unauthorized', { status: 401, headers: { 'WWW-Authenticate': wwwAuth }, }) } const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName } = authResult const supabase = createServiceClientNoCookies() const actor: ActorContext = { type: 'api_key', id: apiKeyId, label: apiKeyName ?? 'Unnamed API key', } // ── Parse JSON-RPC ── let body: JsonRpcRequest try { body = await request.json() } catch { return NextResponse.json( jsonRpcError(null, -32700, 'Parse error: expected JSON-RPC 2.0 request body'), { status: 400 } ) } if (body.jsonrpc !== '2.0' || !body.method) { return NextResponse.json( jsonRpcError(body.id ?? null, -32600, 'Invalid Request: must include jsonrpc="2.0" and method'), { status: 400 } ) } // ── Dispatch ── const { method, id, params } = body switch (method) { case 'initialize': { const SUPPORTED_VERSIONS = new Set(['2025-06-18', '2025-03-26', '2024-11-05']) const clientVersion = (params as Record)?.protocolVersion as string | undefined const negotiatedVersion = clientVersion && SUPPORTED_VERSIONS.has(clientVersion) ? clientVersion : PROTOCOL_VERSION return NextResponse.json( jsonRpc(id ?? null, { protocolVersion: negotiatedVersion, capabilities: { tools: { listChanged: false }, resources: { listChanged: false }, prompts: { listChanged: false }, }, serverInfo: SERVER_INFO, instructions: [ 'gnubok — Swedish double-entry bookkeeping via conversation.', '', 'Discovery:', '• Call gnubok_search_tools first (e.g. query="vat") to load only the schemas you need. tools/list returns names + 1-line summaries; pull full schemas via gnubok_search_tools(detail="full") when invoking.', '• When the user asks "how do I do X" or you\'re unsure of the correct sequence (month-end close, VAT review, year-end, invoicing, payroll), call gnubok_list_skills first — domain workflows are documented as loadable skills with tool references.', '', 'Common workflows:', '• Categorize transactions: gnubok_list_uncategorized_transactions → gnubok_suggest_categories → gnubok_categorize_transaction (stages for user approval). Use gnubok_match_transaction_to_invoice to apply income to a specific invoice.', '• 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.', '• VAT: gnubok_get_vat_report(period_type, year, period). Ruta49 = VAT to pay (positive) or refund (negative).', '• Reporting: gnubok_get_trial_balance / _income_statement / _balance_sheet / _kpi_report / _ar_ledger / _supplier_ledger — all default to the most recent fiscal period.', '• Year-end: gnubok_lock_period → gnubok_run_year_end → gnubok_set_opening_balances → gnubok_close_period. Each stages for human approval; closing is irreversible per BFL.', '• Payroll: gnubok_create_salary_run → gnubok_calculate_salary_run → review/approve in web UI → gnubok_generate_agi.', '', 'Write operations stage a pending_operation (risk_level: low/medium/high) — the user approves in the gnubok web app before any DB write. Pass dry_run=true to preview without staging. Pass idempotency_key to make a write safely retryable.', 'All amounts are SEK unless currency is specified. All dates ISO YYYY-MM-DD. Account numbers are strings (e.g. "1930").', ].join('\n'), }) ) } 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, {})) case 'tools/list': { const listStartedAt = Date.now() const allowedTools = tools.filter((t) => { 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, { tools: allowedTools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema, ...(t.outputSchema ? { outputSchema: t.outputSchema } : {}), annotations: t.annotations, ...(t._meta ? { _meta: t._meta } : {}), })), }) ) } case 'tools/call': { const toolName = (params as Record)?.name as string const toolArgs = ((params as Record)?.arguments ?? {}) as Record< string, unknown > const tool = tools.find((t) => t.name === toolName) if (!tool) { emitToolCallTelemetry({ tool: toolName ?? '', requiredScope: null, actor, latencyMs: 0, success: false, isError: true, errorCode: 'UNKNOWN_TOOL', errorKind: 'unknown_tool', requestId: id ?? null, userId, companyId, }) const available = tools.map((t) => t.name).join(', ') return NextResponse.json( jsonRpcError(id ?? null, -32602, `Unknown tool: "${toolName}". 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', requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpc(id ?? null, { content: [{ type: 'text', text: JSON.stringify(scopeError, null, 2) }], isError: true, }) ) } 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 } const result = await tool.execute(toolArgs, companyId, userId, supabase, actor) 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 } } emitToolCallTelemetry({ tool: toolName, requiredScope: requiredScope ?? null, actor, latencyMs, success: true, isError: false, errorCode: null, errorKind: null, requestId: id ?? null, userId, companyId, }) return NextResponse.json(jsonRpc(id ?? null, response)) } catch (err) { const latencyMs = Date.now() - callStartedAt const structured = toToolError(err, { toolName }) emitToolCallTelemetry({ tool: toolName, requiredScope: requiredScope ?? null, actor, latencyMs, success: false, isError: true, errorCode: structured.error.code, errorKind: 'execution', requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpc(id ?? null, { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], isError: true, }) ) } } case 'resources/list': return NextResponse.json( jsonRpc(id ?? null, { resources: [ ...uiWidgets.map((w) => ({ uri: w.uri, name: w.name, description: w.description, mimeType: WIDGET_MIME_TYPE, })), ...skills.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, })), ], }) ) 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, { contents: [ { uri, mimeType: WIDGET_MIME_TYPE, text: widget.html, }, ], }) ) } // Skills exposed at gnubok://skill/ — Markdown bodies, forward-compatible // with a future native MCP skills/list primitive. if (uri.startsWith(SKILL_URI_PREFIX)) { const slug = skillSlugFromUri(uri) const skill = slug ? findSkill(slug) : 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, { contents: [ { uri, mimeType: SKILL_MIME_TYPE, text: skill.body, }, ], }) ) } } const dataResource = findResource(uri) if (dataResource) { try { const result = await dataResource.read({ supabase, companyId, userId, scopes: keyScopes, query: parseResourceQuery(uri), }) emitResourceReadTelemetry({ uri, kind: 'data', success: true, errorCode: null, actor, latencyMs: Date.now() - readStartedAt, requestId: id ?? null, userId, companyId, }) return NextResponse.json( jsonRpc(id ?? null, { contents: [ { uri, mimeType: dataResource.mimeType, text: JSON.stringify(result, null, 2), }, ], }) ) } 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, `Resource read error: ${message}`) ) } } 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, { prompts: prompts.map((p) => ({ name: p.name, description: p.description, })), }) ) 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, { description: prompt.description, messages: [ { role: 'user', content: { type: 'text', text: prompt.text }, }, ], }) ) } default: return NextResponse.json( jsonRpcError(id ?? null, -32601, `Method not found: "${method}"`) ) } }