* feat(mcp): agent-native improvements — progressive discovery, widgets, skills, telemetry
Four coordinated streams of MCP server improvements that move gnubok toward
agent-first design, grounded in Anthropic's Nov 2025 "Code execution with MCP"
article and the May 2026 MCP conference talk.
Context budget — minimize tools/list payload
- New gnubok_search_tools: progressive discovery with name|summary|full detail
levels and scope filtering. Agents pull only the schemas they need.
- Trimmed all 50 tool descriptions from multi-paragraph blocks (avg ~500-1000
chars) to one-sentence summaries (avg ~120 chars). Args/Returns/Examples
blocks dropped — they duplicated inputSchema.
- outputSchema declared on every tool; structuredContent emitted on every
successful tools/call (was previously only widget-tagged tools).
- protocolVersion bumped to 2025-06-18 (negotiates back to 2024-11-05).
- Workflow examples consolidated into initialize.instructions.
- Net effect: tools/list payload ~43 KB / ~10.8K tokens for 51+ tools, with
headroom guard at 20K tokens.
MCP applications — server-shipped UI widgets
- New widgets/ directory with typed UiWidget contract; receipt-matcher moved
out of widget-html.ts (which was deleted) into widgets/receipt-matcher.ts.
- New gnubok_vat_review_widget tool + interactive momsdeklaration widget
(all 8 rutor with summary card, theme-aware light/dark, copy buttons).
- resources/list and resources/read iterate uiWidgets dynamically — adding
the next widget is a single file drop.
Skills over MCP — domain-knowledge primitive
- 5 user-facing SKILL.md-style workflow guides authored from existing
.claude/skills/swedish-* development skills:
• month-end-close — book → reconcile → VAT (monthly filers) → lock
• quarterly-vat-review — ruta-by-ruta map, deadlines, common errors
• year-end-close — bokslut, bokslutstransaktioner, lock → year-end
→ opening balances → close (irreversible)
• invoicing-rules — ML 17 kap. 24 §, customer types, ROT/RUT, Peppol
• payroll-monthly — salary run → calculate → review → AGI XML
- gnubok_list_skills (with optional tag filter) + gnubok_load_skill(slug).
Both unscoped — available to any authenticated key.
- Each skill also exposed as MCP resource at gnubok://skill/<slug>
(text/markdown) for forward compatibility with a future native
skills/list primitive.
Tool-call telemetry — measure before optimizing further
- Three new CoreEvent types (mcp.tool_called, mcp.tools_list_called,
mcp.resource_read), all persisted to event_log (30-day TTL, RLS-scoped).
- Fire-and-forget emission from the dispatcher — never blocks JSON-RPC
response, double-guarded against handler failures.
- tools/call instrumented at all four exit points (success, execution
error, scope denied, unknown tool). Latency measured tightly around
tool.execute() — excludes dispatcher overhead.
- tools/list logs returned tool count (informs progressive-discovery
adoption); resources/read logs URI + kind discriminator (widget /
skill / data / unknown).
- No PII or secret material in payloads — only metadata.
Out of scope (explicitly deferred):
- Code-mode SDK (no production code-mode hosts to consume it yet).
- Elicitations (require streamable HTTP transport — bigger architectural lift).
- DB lockdown / RPC funnel (foundational; should follow once telemetry tells
us where writes actually flow).
- CRUD → intent endpoints (frontend coupling — multi-PR effort).
Tests: +37 new unit tests across search-tools, output-schema, payload-size,
vat-review-widget, skills, telemetry. Existing receipt-matcher test updated
for the new structuredContent contract. 2,615 unit tests passing.
Production build green. No new lint warnings or errors in changed files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mcp): address PR #393 review findings
Greptile (P1 + P2) and the Swedish accounting compliance bot flagged 9 issues
across security, data correctness, and skill content. All addressed:
Security (P1)
- gnubok_search_tools: scope filter now fails closed when __keyScopes is
absent. The earlier permissive default leaked the full tool inventory if
the dispatcher's hard-coded name check ever silently broke. Marker presence
is part of the contract — explicitly empty array also hides scoped tools.
Two new test cases pin the fail-closed behaviour.
Compliance — VAT (data correctness)
- get_vat_report now aggregates 2614/2624/2634 (reverse-charge output VAT)
and exposes them as ruta30/ruta31/ruta32 per SKV 4700. ruta48 also picks
up 2647 (missing before). ruta49 formula corrected to
(10+11+12+30+31+32) − 48. The widget renders the new rutor between the
Utgående and Ingående sections.
- Widget ruta 05 sub-label updated from "3001+3002+3003" to "all momspliktig
försäljning oavsett skattesats" — ruta 05 covers all domestic taxable
supplies, not just direct-rate sales.
- Refactored: extracted computeVatReport() helper used by both
gnubok_get_vat_report and gnubok_vat_review_widget. Removes the
rename-fragile tools.find() lookup at runtime.
Compliance — payroll
- payroll-monthly skill: replaced "born 1958 or earlier = 10.21%" (the 2024
formulation) with the statutory rule "age 66+ on 1 January of the income
year (67+ from income year 2026)". Removed the unsourced "age 16–18:
11.78%" row in favour of a current växa-stöd description with explicit
Prop. 2025/26:34 reference and a "verify against current Skatteverket
tables" caveat.
Compliance — skills text
- invoicing-rules: added explicit footnote on the 1 April 2026 livsmedel
rate change. Restaurang/servering stays at 12 %; livsmedel sold in other
forms drops to 6 %. Per Prop. 2025/26:55.
- year-end-close: clarified periodiseringsfond cap as "25 % of överskott
before this year's avsättning" (IL 30 kap.), removing the ambiguous
"skattemässigt resultat" phrasing that could be misread as a circular
after-fond computation.
Schema correctness
- STAGED_OPERATION_SCHEMA.required gains "staged" — every path through
stagePendingOperation returns the field, so the schema now matches the
contract that MCP clients validate against.
Tests: 2,617 passing (+2 for the search-tools fail-closed cases).
Production build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mcp): address PR #393 round-2 compliance review
Round 2 of the swedish-compliance bot ran after the previous fix-push and
flagged four substantive items + a recommendation. All addressed:
VAT computation (data correctness)
- ruta05 expanded beyond 3001/3002/3003 to cover the common BAS taxable-
revenue accounts (3001-3008, 3041-3048, 3051-3058, 3071-3078). Companies
that book to 30xx alternates were previously under-reporting taxable
turnover; now all standard BAS taxable-revenue numbering contributes.
- One-sided reverse-charge warning: when output VAT is booked on
2614/2624/2634 (rutor 30/31/32 > 0) but the matching calculated input
VAT (2645) is zero, computeVatReport now returns a Swedish-language
warning string. ruta49 is inflated in this case — the warning surfaces
the most common reverse-charge error per the swedish-vat skill. The
widget renders warnings in a terracotta panel above the summary card.
- computeVatReport exported and a focused unit test added — exercises 2647
inclusion in ruta48, reverse-charge balanced/unbalanced cases, and the
expanded ruta05 mapping. Fills the gap that prior tools/call integration
tests couldn't reach.
Skill content
- payroll-monthly Step 6: BAS journal-entry example no longer hard-codes
31.42 % on the 7510/2730 lines. The avgift line is now described as
"avgift_base × applicable_rate per employee" with explicit aggregation
semantics for runs that mix full-rate and reduced-rate employees.
Aligns with the Step-4 reduced-rate caveats already in place.
- invoicing-rules ROT/RUT block: replaced the bare "30 % / max 50 000 SEK"
text with the full year-by-year picture — RUT 50 % / 75 000 max, ROT
baseline 30 % / 50 000 max, 2024 H2 doubled ceiling, 2025 May–Dec
enhanced 50 % rate. Defaults to "verify against current Skatteverket
table" rather than a single hard-coded rate.
False positives in the round-2 review (no fix needed; documented for the
record):
- "Old vat_report path still computes ruta48 without 2647" — the old path
was replaced by computeVatReport in the previous push; the bot was
reading the diff hunk and conflating it with current behaviour.
- "Widget prose says ruta49 = (10+11+12) - 48" — no such prose exists in
widgets/vat-review.ts. The skill body has the correct
(10+11+12+30+31+32)-48 formula.
- "Including 'reversed' status entries in VAT aggregation may over-count
cross-period storno" — current behaviour is correct per Skatteverket
period-aligned filing: the reversed original stays in its own period,
the matching storno (status 'posted') lands in the reversal period,
and they net to zero across the full year. Adding code comment to
document.
Tests: 2,623 passing (+6 for computeVatReport unit tests). Production
build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mcp): address PR #393 round-3 compliance review
Compliance bot re-ran after the round-2 push and flagged three items:
VAT computation
- One-sided reverse-charge warning previously only checked 2645 (EU
acquisitions). For domestic reverse charge per ML 16:13 (byggtjänster,
electronics > 100k SEK, etc.) the matching input lands on 2647 — a
correctly-balanced 2614+2647 booking would have falsely fired the
warning. Fixed: warning now triggers only when *both* 2645 and 2647
are zero. Updated message text mentions both accounts. Added a test
case asserting the no-warning path for 2647-only-input.
Skill documentation drift
- quarterly-vat-review skill body still showed ruta05 source as
"3001 + 3002 + 3003" while the runtime computeVatReport sums 32 BAS
taxable-revenue accounts. Updated the skill table to read
"3001–3008, 3041–3048, 3051–3058, 3071–3078" so the auditor-facing
docs match the implementation.
outputSchema upgrade
- gnubok_get_vat_report and gnubok_vat_review_widget previously declared
outputSchema as the bare { type: 'object' }. Replaced with a shared
VAT_REPORT_OUTPUT_SCHEMA constant declaring period, period_label, all
11 rutor (with descriptions referencing source accounts), summary,
and warnings. Modern MCP clients that validate structuredContent
against outputSchema now have an accurate contract. Added a test
asserting the schema is non-trivial and declares every ruta the
runtime returns.
Tests: 2,625 passing (+2 for the 2647 warning path and the
outputSchema shape assertion). Production build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mcp): address PR #393 round-4 compliance review
The compliance bot re-ran after the round-3 push with a fresh batch.
Real findings fixed; false positives documented.
VAT computation
- Removed 3004 (Försäljning inom Sverige, momsfri / VAT-exempt) from
RUTA_05_ACCOUNTS — round-2's expansion accidentally included it. Ruta 05
is the *taxable* base; exempt sales must NOT contribute. New test pins
the exclusion.
- Added 3106 (taxable EU goods supply, momspliktig) to RUTA_05_ACCOUNTS.
Used when EU buyer's VAT number is invalid or buyer is private.
- Added ruta35 — EU intra-community goods supplies, momsfri (account
3108). Previously omitted entirely from the rutor schema; SKV 4700
has it as a distinct box separate from ruta 39 (services) and ruta 40
(export outside EU). VatReportResult, VAT_REPORT_OUTPUT_SCHEMA, the
widget table, the copy-summary block, and the quarterly-vat-review
skill table all updated. New test covers 3108 → ruta35 mapping.
- Strengthened the comment on the posted+reversed status filter to
document why current behavior is correct per ML 2023:200 and
faktureringsmetoden (the bot's cross-period storno concern is a false
positive — see commit message rationale below).
Skill content
- invoicing-rules: added explicit BFL 5 kap. 6–7 § / ML 17 kap. 22–23 §
note that the kreditfaktura itself consumes a sequential number from
the same (or dedicated KR-) fakturaserie. The KR- prefix is a display
convention; the underlying löpnummer must be unbroken just like the
regular series.
- payroll-monthly: added the missing "born 1937 or earlier → 0 %"
cohort to the rate breakdown. Previously could lead a payroll run to
over-pay avgifter on the oldest cohort.
False positives in the round-4 review (verified, not changed)
- 2644/2648 as reverse-charge inputs: verified against gnubok's actual
BAS chart (lib/bookkeeping/bas-data/class-2-equity-liabilities.ts).
2644 does not exist; 2648 is "Vilande ingående moms" (dormant input
VAT for cash method), not RC at 6 %. Canonical RC inputs are 2645 (EU)
and 2647 (domestic) — both already covered.
- Cross-period storno over-count: per ML 2023:200 + Skatteverket
faktureringsmetoden, the original sale's VAT belongs to the invoice-
date period; the kreditfaktura's reduction belongs to the storno-date
period. Including 'reversed' status entries (which still have their
original date) is therefore correct. *Excluding* them would
under-report the original period and over-credit the reversal period.
Added a multi-line comment in computeVatReport documenting this.
Tests: 2,627 passing (+2 for ruta35 mapping and 3004 exclusion).
Production build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4213 lines
156 KiB
TypeScript
4213 lines
156 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 { shouldAutoCommit } from '@/lib/pending-operations/should-auto-commit'
|
||
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
||
import {
|
||
checkIdempotencyKey,
|
||
storeIdempotencyResponse,
|
||
hashRequest,
|
||
IdempotencyKeyReuseError,
|
||
} from '@/lib/api/idempotency'
|
||
import { toToolError } from './tool-result'
|
||
import type { PendingOperation } from '@/types'
|
||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
|
||
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
|
||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||
import { closePeriod, lockPeriod } from '@/lib/core/bookkeeping/period-service'
|
||
import { generateSIEExport } from '@/lib/reports/sie-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
|
||
auto_committed: boolean
|
||
auto_commit_reason?: string
|
||
result?: Record<string, unknown>
|
||
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,
|
||
auto_committed: false,
|
||
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>>
|
||
}
|
||
}
|
||
|
||
// Decide auto-commit eligibility BEFORE insert so we can persist the flag.
|
||
const previewAmount =
|
||
typeof previewData.amount === 'number' ? previewData.amount as number :
|
||
typeof previewData.total === 'number' ? previewData.total as number :
|
||
null
|
||
|
||
const decision = await shouldAutoCommit(supabase, companyId, {
|
||
operationType,
|
||
actorType: actor.type,
|
||
amount: previewAmount,
|
||
})
|
||
|
||
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,
|
||
auto_commit_eligible: decision.eligible,
|
||
})
|
||
.select('*')
|
||
.single()
|
||
|
||
if (error) throw new Error(`Failed to stage operation: ${error.message}`)
|
||
|
||
if (!decision.eligible) {
|
||
const response = {
|
||
staged: true,
|
||
operation_id: data.id,
|
||
risk_level: riskLevel,
|
||
actor,
|
||
auto_committed: false,
|
||
auto_commit_reason: decision.reason,
|
||
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, auto_committed: false, preview: previewData }
|
||
)
|
||
}
|
||
return response
|
||
}
|
||
|
||
// Auto-commit path: invoke the dispatcher inline so the same audit
|
||
// trail/event-emission logic runs as for human approvals.
|
||
const commitResult = await commitPendingOperation(
|
||
supabase,
|
||
userId,
|
||
companyId,
|
||
data as PendingOperation,
|
||
{ isAutoCommit: true }
|
||
)
|
||
|
||
const response = {
|
||
staged: true,
|
||
operation_id: data.id,
|
||
risk_level: riskLevel,
|
||
actor,
|
||
auto_committed: commitResult.status === 'committed',
|
||
auto_commit_reason: decision.reason,
|
||
result: commitResult.data,
|
||
message: commitResult.status === 'committed'
|
||
? `Auto-committed by ${actor.label ?? actor.type} (risk: ${riskLevel}).`
|
||
: `Auto-commit failed: ${commitResult.error ?? 'unknown'}. Review in the ${branding} web app.`,
|
||
preview: previewData,
|
||
...(next ? { next } : {}),
|
||
} as const
|
||
|
||
if (options.idempotencyKey && requestHash) {
|
||
await storeIdempotencyResponse(
|
||
supabase, userId, companyId, options.idempotencyKey, requestHash,
|
||
commitResult.status === 'committed' ? 'success' : 'error',
|
||
{
|
||
staged: true,
|
||
operation_id: data.id,
|
||
auto_committed: commitResult.status === 'committed',
|
||
result: commitResult.data,
|
||
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' },
|
||
auto_committed: { type: 'boolean' },
|
||
auto_commit_reason: { type: 'string' },
|
||
dry_run: { type: 'boolean' },
|
||
idempotency_replay: { type: 'boolean' },
|
||
message: { type: 'string' },
|
||
preview: { type: 'object' },
|
||
result: { type: 'object' },
|
||
next: { type: 'object' },
|
||
},
|
||
required: ['staged', 'risk_level', 'actor', 'auto_committed', '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) − 48',
|
||
},
|
||
},
|
||
required: ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49'],
|
||
},
|
||
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
|
||
}
|
||
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')
|
||
const calculatedInput2645 = debitBalance('2645')
|
||
const calculatedInput2647 = debitBalance('2647')
|
||
const ruta48 = debitBalance('2641') + calculatedInput2645 + calculatedInput2647
|
||
const ruta49 = Math.round(
|
||
(ruta10 + ruta11 + ruta12 + ruta30 + ruta31 + ruta32 - 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,
|
||
},
|
||
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,
|
||
}
|
||
}
|
||
|
||
// ── 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_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_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)
|
||
},
|
||
},
|
||
|
||
// ── 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_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
|
||
)
|
||
},
|
||
},
|
||
|
||
// ── 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, status')
|
||
.eq('company_id', companyId)
|
||
.order('period_start', { ascending: false })
|
||
|
||
if (error) throw new Error(`Database error: ${error.message}`)
|
||
|
||
return { periods: data ?? [], count: data?.length ?? 0 }
|
||
},
|
||
},
|
||
|
||
// ── 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
|
||
},
|
||
},
|
||
// ── Payroll (Lönehantering) ──────────────────────────────────
|
||
{
|
||
name: 'gnubok_list_employees',
|
||
description: 'List employees for the active company. Personnummer returned masked (XXXXXXXX-NNNN).',
|
||
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_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: `XXXXXXXX-${e.personnummer_last4}` }))
|
||
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_last4)')
|
||
.eq('salary_run_id', id)
|
||
return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record<string, unknown>), personnummer: `XXXXXXXX-${(e.employee as Record<string, unknown>).personnummer_last4}` } : 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(),
|
||
}
|
||
},
|
||
},
|
||
|
||
// ── Stream 1 Phase 1 follow-up: year-end, opening balances, revaluation,
|
||
// voucher gaps, supplier-invoice lifecycle, proforma conversion ──
|
||
|
||
{
|
||
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
|
||
)
|
||
},
|
||
},
|
||
]
|
||
|
||
// ── 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}"`)
|
||
)
|
||
}
|
||
}
|