* fix(salary): show birthdate in masked personnummer, hide the 4-digit suffix Flip the personnummer display format from XXXXXXXX-NNNN to YYYYMMDD-XXXX so the sensitive 4-digit suffix is hidden while the (public) birthdate stays visible. Affects the employees list/detail, salary run, payslip PDF, payslip email, and the MCP server tools (list_employees, get_salary_run). Each call site now decrypts the stored personnummer before masking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(transactions): allow deleting unbooked transactions from "Alla transaktioner" The history list only let users delete via the inbox card; once a category or mall was picked but the verifikation hadn't been created, the row showed "Ej bokförd" with no way to remove it. The API already permits delete while journal_entry_id is null, so the gap was purely a missing UI affordance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vat): populate ruta 20-24 for reverse charge + dishonest "Validera OK" Three connected issues caused Skatteverket to reject momsdeklarationer with FK004 even after our local "Validera"-knapp returned OK. 1. supplier-invoice-entries booked fiktiv moms (2614/2624/2634 + 2645/2647) on reverse-charge invoices but never the underlying basbelopp on 44xx/45xx. Ruta 30-32 filled up at SKV while ruta 20-24 stayed at 0 — SKV's FK004 ("silent netting prohibited", ML 13 kap kräver båda sidor). Fix: generateReverseChargeBasisLines in vat-entries.ts emits parallel 45xx/44xx debit + 4598 motkonto credit per rate group. Engine calls it from registration, cash, and credit-note paths. Skipped when the user booked the expense directly on a basis account to avoid double-counting. 4598 added to BAS reference (no migration needed; account_number is plain text on journal_entry_lines). 2. rutorToMomsuppgift rounded each ruta independently but computed summaMoms from the unrounded ruta49. SKV recomputes the sum from integer rutor on their side, so fractional öres caused ±1 SEK drift and SKV rejected with FK009. Fix: derive summaMoms from the already-rounded VAT-amount rutor. 3. "Validera"-knappen only confirmed SKV's internal arithmetic — a declaration with ruta 30-32 populated and ruta 20-24 empty validated fine until /utkast hit FK004. Users got a false green light. Fix: vat-declaration-checks.ts runs locally before the SKV call, blocks Validera/Spara when ERROR-level findings exist, and surfaces them in a separate "Lokala kontroller"-section. Success message reworded so SKV's OK is no longer presented as filing-ready. Tests: 4535/4536/4531/4425 lines + 4598 motkonto on EU/non-EU/byggtjänster RC, credit-note reversal, fractional-öres summaMoms, all four pre-flight codes (RC_BASIS_MISSING, RC_OUTPUT_MISSING, RC_INPUT_VAT_MISMATCH, SUMMA_MOMS_DRIFT). Backfill for already-posted entries follows in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: add skattekonto matching functionality - Enhance TransactionInboxCard to display a warning for potential 1930↔1630 transfers. - Implement match suggestions for skattekonto transactions in the backend. - Create SkattekontoMatchDialog component for linking skattekonto rows to existing journal entries. - Develop SkattekontoInboxCard component to handle skattekonto transactions in the inbox. - Introduce skattekonto-match utility functions for candidate matching and linking. - Update types to include match suggestions and enriched transaction responses. * refactor: reorganize skattekonto types and implement bank counterpart matching logic * docs: update CLAUDE.md to streamline integrations and clarify architecture details * refactor: enhance reverse charge logic to handle non-basis accounts and prevent double-counting --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6281 lines
245 KiB
TypeScript
6281 lines
245 KiB
TypeScript
import { NextResponse } from 'next/server'
|
||
import {
|
||
extractBearerToken,
|
||
validateApiKey,
|
||
createServiceClientNoCookies,
|
||
hasScope,
|
||
TOOL_SCOPE_MAP,
|
||
} from '@/lib/auth/api-keys'
|
||
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 } 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<string, unknown>
|
||
}
|
||
|
||
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<string, unknown>
|
||
outputSchema?: Record<string, unknown>
|
||
annotations: McpToolAnnotations
|
||
_meta?: { ui: { resourceUri: string } }
|
||
execute: (
|
||
args: Record<string, unknown>,
|
||
companyId: string,
|
||
userId: string,
|
||
supabase: SupabaseClient,
|
||
actor?: ActorContext
|
||
) => Promise<unknown>
|
||
}
|
||
|
||
// ── Shared constants ─────────────────────────────────────────
|
||
|
||
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<string, unknown>
|
||
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
|
||
}
|
||
|
||
async function stagePendingOperation(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
userId: string,
|
||
operationType: string,
|
||
title: string,
|
||
params: Record<string, unknown>,
|
||
previewData: Record<string, unknown>,
|
||
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<string, unknown>
|
||
next?: StageNextHint
|
||
}> {
|
||
const riskLevel = getRiskLevel(operationType)
|
||
const branding = getBranding().appName.toLowerCase()
|
||
|
||
// ── 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,
|
||
...(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<string, unknown>),
|
||
idempotency_replay: true,
|
||
risk_level: riskLevel,
|
||
actor,
|
||
message: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`,
|
||
preview: previewData,
|
||
} as Awaited<ReturnType<typeof stagePendingOperation>>
|
||
}
|
||
}
|
||
|
||
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: previewData,
|
||
...(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' },
|
||
next: { type: 'object' },
|
||
},
|
||
required: ['staged', 'risk_level', 'actor', 'message', 'preview'],
|
||
} as const
|
||
|
||
function paginatedSchema(itemsKey: string, itemSchema: Record<string, unknown> = { 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',
|
||
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<string, unknown>,
|
||
companyId: string,
|
||
supabase: SupabaseClient
|
||
): Promise<VatReportResult> {
|
||
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<string, { debit: number; credit: number }>()
|
||
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<number, { m: number; yOffset: number }> = {
|
||
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<string, unknown>,
|
||
companyId: string,
|
||
supabase: SupabaseClient
|
||
): Promise<VatCloseCheckResult> {
|
||
// 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',
|
||
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',
|
||
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<string, unknown>).__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',
|
||
properties: {
|
||
tag: { type: 'string', description: 'Optional filter — return only skills matching this tag (e.g. "vat", "monthly", "yearly", "payroll").' },
|
||
},
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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<Record<string, unknown>> | 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',
|
||
}
|
||
)
|
||
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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 for the title
|
||
const { data: tx } = await supabase
|
||
.from('transactions')
|
||
.select('description, merchant_name, amount, currency')
|
||
.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
|
||
)
|
||
},
|
||
},
|
||
|
||
// ── 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',
|
||
properties: {
|
||
limit: { type: 'number', description: 'Max transactions to show, 1–50 (default 20)' },
|
||
},
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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', properties: {} },
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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',
|
||
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<string, unknown>) => ({
|
||
id: inv.id,
|
||
invoice_number: inv.invoice_number,
|
||
status: inv.status,
|
||
customer_name: (inv.customers as Record<string, unknown>)?.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',
|
||
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',
|
||
properties: {
|
||
period_id: { type: 'string', description: 'Fiscal period UUID (default: most recent)' },
|
||
},
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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<string, { debit: number; credit: number }>()
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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
|
||
)
|
||
},
|
||
},
|
||
|
||
{
|
||
name: 'gnubok_send_invoice',
|
||
description: 'Send invoice via email with PDF attachment. Stages for approval. Requires customer email + email service configured.',
|
||
inputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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', properties: {} },
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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',
|
||
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',
|
||
properties: {
|
||
limit: { type: 'number', description: 'Max results 1–200 (default 100)' },
|
||
},
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
properties: {
|
||
transaction_ids: {
|
||
type: 'array',
|
||
items: { type: 'string' },
|
||
description: 'Up to 20 transaction UUIDs',
|
||
},
|
||
},
|
||
required: ['transaction_ids'],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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<string, number> = {}
|
||
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<string, unknown[]> = {}
|
||
const counterpartyResult: Record<string, unknown> = {}
|
||
|
||
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',
|
||
properties: {
|
||
account_class: { type: 'number', description: 'Filter by class (1–8)' },
|
||
active_only: { type: 'boolean', description: 'Only active accounts (default: true)' },
|
||
},
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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<string, unknown>)?.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',
|
||
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',
|
||
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<Proposal, 'decision'> = {
|
||
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', properties: {} },
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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',
|
||
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',
|
||
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<string, string> = {
|
||
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<string, unknown>,
|
||
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',
|
||
properties: {
|
||
status: { type: 'string', enum: ['received', 'error'], description: 'Filter by status' },
|
||
limit: { type: 'number', description: 'Max results (default 20, max 50)' },
|
||
},
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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<string, unknown> | null
|
||
let vendorName: string | null = null
|
||
let amount: number | null = null
|
||
let invoiceDate: string | null = null
|
||
|
||
if (extracted) {
|
||
const supplier = extracted.supplier as Record<string, unknown> | undefined
|
||
const invoice = extracted.invoice as Record<string, unknown> | undefined
|
||
const totals = extracted.totals as Record<string, unknown> | 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',
|
||
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',
|
||
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<string, unknown> | null) ?? null
|
||
if (!extracted) throw new Error('Inbox item has no extracted_data — re-run extraction first')
|
||
|
||
const supplierExt = extracted.supplier as Record<string, unknown> | undefined
|
||
const invoiceExt = extracted.invoice as Record<string, unknown> | undefined
|
||
const totalsExt = extracted.totals as Record<string, unknown> | undefined
|
||
const lineItemsExt = (extracted.lineItems as Array<Record<string, unknown>> | 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',
|
||
properties: {
|
||
limit: { type: 'number', description: 'Max results (default 20, max 50)' },
|
||
cursor: { type: 'string', description: 'Composite "<created_at>__<inbox_item_id>" from previous page (exclusive). Pass next_cursor verbatim.' },
|
||
},
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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: "<created_at>__<id>". 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<string, unknown> | 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<string, unknown> | undefined
|
||
const invoice = extracted.invoice as Record<string, unknown> | undefined
|
||
const totals = extracted.totals as Record<string, unknown> | 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',
|
||
properties: {
|
||
document_id: { type: 'string', description: 'UUID of the document_attachments row' },
|
||
},
|
||
required: ['document_id'],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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<string, unknown>
|
||
const supplier = ext.supplier as Record<string, unknown> | undefined
|
||
const invoice = ext.invoice as Record<string, unknown> | undefined
|
||
const totals = ext.totals as Record<string, unknown> | 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',
|
||
properties: {
|
||
active_only: { type: 'boolean', description: 'Only active employees (default: true)' },
|
||
},
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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<string, unknown>), personnummer: maskPersonnummer(decryptPersonnummer((e.employee as Record<string, unknown>).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',
|
||
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',
|
||
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',
|
||
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',
|
||
properties: {
|
||
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
|
||
},
|
||
required: ['salary_run_id'],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
properties: {
|
||
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period to export' },
|
||
},
|
||
required: ['fiscal_period_id'],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
properties: {
|
||
fiscal_period_id: { type: 'string' },
|
||
voucher_series: { type: 'string', description: 'Optional series filter (e.g. "A")' },
|
||
},
|
||
required: ['fiscal_period_id'],
|
||
},
|
||
outputSchema: {
|
||
type: 'object',
|
||
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',
|
||
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',
|
||
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, 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,
|
||
},
|
||
actor
|
||
)
|
||
},
|
||
},
|
||
|
||
{
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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',
|
||
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<Record<string, unknown>> | 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<string, { name: string; active: boolean }>()
|
||
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
|
||
)
|
||
},
|
||
},
|
||
|
||
{
|
||
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',
|
||
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<Record<string, unknown>> | 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 } | { name?: string; is_closed?: boolean }[] | 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), 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) {
|
||
throw new Error(
|
||
`Fiscal period "${periodInfo.name ?? 'okänd'}" is 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
|
||
)
|
||
},
|
||
},
|
||
]
|
||
|
||
// ── 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<Response> {
|
||
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<string, unknown>)?.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<string, unknown>)?.name as string
|
||
const toolArgs = ((params as Record<string, unknown>)?.arguments ?? {}) as Record<
|
||
string,
|
||
unknown
|
||
>
|
||
|
||
const tool = tools.find((t) => t.name === toolName)
|
||
if (!tool) {
|
||
emitToolCallTelemetry({
|
||
tool: toolName ?? '<unknown>',
|
||
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<string, unknown>).__keyScopes = keyScopes
|
||
}
|
||
const result = await tool.execute(toolArgs, companyId, userId, supabase, actor)
|
||
const latencyMs = Date.now() - callStartedAt
|
||
const response: Record<string, unknown> = {
|
||
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<string, unknown>)?.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/<slug> — 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<string, unknown>)?.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}"`)
|
||
)
|
||
}
|
||
}
|