Files
accounted/extensions/general/mcp-server/server.ts
T
Jakob WennbergandClaude Opus 4.7 fd4274787c feat(bokslut): Phases 4-7 — accruals + årsredovisning + EF + MCP tools (#509)
* feat(bokslut): Phases 4-7 — accruals + årsredovisning + EF + MCP tools

Builds on the Phase 1-3 PR (#508). Same K2-AB scope decisions hold (iXBRL
deferred, K3 deferred, koncernredovisning out of scope) — what ships:

PHASE 4 — Periodiseringar
- lib/bokslut/accruals/accrual-detector.ts: auto-proposes the vacation-
  liability change (delta on 2920 against 7090 + 31,42 % avgifter on
  7519/2940) by reading lib/reports/vacation-liability.ts. Manual prepaid
  (17xx) / accrued (29xx) / audit-fee builders for entries the heuristic
  can't derive — supplier-invoice service_period detection is deferred
  until the data model grows the field.
- /api/bookkeeping/fiscal-periods/[id]/accruals (GET + POST) — posts each
  accrual as a separate manual-source journal entry with the next-day
  reverse date embedded in the description.
- New AccrualsStep wizard step between Preflight and Dispositions.
- Auto-reversal cron is follow-up infra; for now reverses_on is metadata
  + a visible UI badge.

PHASE 5 — Årsredovisning PDF + signing
- lib/bokslut/arsredovisning/{types,build-data,arsredovisning-pdf,signature
  -service}: pre-fills flerårsöversikt from prior 3 fiscal periods, eget-
  kapital-förändring from journal data, K2-minimum noter with
  avskrivningstider auto-derived from the asset register and medelantal
  anställda from the employees table. PDF via @react-pdf/renderer mirroring
  the income-statement/pdf pattern.
- Migration 20260516170000: arsredovisning_signature_requests with RLS,
  signed-immutability trigger, and DELETE policy that blocks signed rows.
  Signature-service exposes list/create/markSigned/isFullySignedOff.
- BankID call itself is not wired here — the table + service make the
  request layer available so a follow-up can hook lib/auth/bankid.ts to the
  sign action without rework.
- /api/.../arsredovisning (data + pdf + signatures) endpoints.
- /bookkeeping/year-end/arsredovisning page with editable narrative,
  flerårsöversikt table, signer slots, PDF download, and Bolagsverket
  Mina Sidor link. Explicit warning about the FY2026 iXBRL mandate.

PHASE 6 — Enskild firma NE-bilaga UI
- lib/bokslut/enskild-firma/{egenavgifter,rantefordelning,periodiseringsfond
  -ef,expansionsfond}-calculator.ts. All declaration-only — never produce
  a journal entry. Egenavgifter 28,97 % / pensionärssats 10,21 % / passive
  SLP 24,26 %, schablonavdrag 25/10/20 %. Räntefördelning SLR+6 / SLR+1
  with the -500 000 negative threshold. P-fond EF cap 30 % (vs 25 % för
  AB). Expansionsfond 125,94 % av kapitalunderlag, 20,6 % skatt.
- EfDeclarationSection mounted inside DispositionsStep when entity_type is
  enskild_firma — live recompute as the user adjusts kapitalunderlag,
  prior-year amounts, p-fond desired, expansionsfond change. Each card
  shows the NE-bilaga ruta the number lands in. NE-bilaga preview link.

PHASE 7 — Agent-native MCP tools
- gnubok_propose_dispositioner: read-only AB dispositions proposal
- gnubok_propose_accruals: read-only accruals proposal
- gnubok_propose_annual_depreciation: read-only depreciation per asset
- gnubok_post_annual_depreciation: stages depreciation commit (high-risk)
- gnubok_preview_arsredovisning: structured K2 ÅR preview
- gnubok_preview_ef_declaration: EF skattemässiga justeringar preview
- All ≤280-char descriptions, additionalProperties:false, conform to
  STAGED_OPERATION_SCHEMA for write tools. Read tools return the same
  shapes as their HTTP counterparts so agents and the UI share a contract.
- The existing GET /bokslutsdispositioner endpoint now calls the shared
  buildDispositionsProposal helper that the MCP tool also uses, removing
  the duplicate logic that lived in both.

Verification
- 149 unit tests pass (was 125 on Phase 1-3; +24 across accruals, EF
  calculators, expansionsfond, periodiseringsfond-ef)
- Zero lint or typecheck errors on any new file
- Migration 20260516170000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #509 round-1 — CI blocker + 3 P1s + compliance bundle

CI BLOCKER (output-schema test)
- The 5 new read-only MCP tools (gnubok_propose_dispositioner / _accruals /
  _annual_depreciation, _preview_arsredovisning / _ef_declaration) were
  missing outputSchema, which the existing strict-schemas guard reads as a
  hard failure on core-only CI. Added a permissive { type: 'object',
  additionalProperties: true } outputSchema to each — the return shapes are
  the same as their HTTP counterparts and trying to mirror them inline
  would duplicate the type tree across two boundaries.

P1 — vacation accrual: two real bugs in one entry
- Delta was anchored on the OPENING balance of 2920, so any mid-year
  postings (partial accruals, reversals) were ignored. Now anchors on the
  current closing balance via tb.rows.find(2920).closing_credit -
  closing_debit. Updated the computation field label to current_2920.
- More importantly: 2920 is a balance-sheet carry-forward (semesterlöneskuld
  persists until the actual vacation is paid). The original implementation
  set reverses_on to Jan 1 of the next year, which would zero the liability
  on day 1 of the new year — a known Swedish bookkeeping error. The
  vacation proposal now ships with reverses_on = '' to suppress the
  reversal badge, the API route emits a "Bokslutsjustering" description
  instead of "Periodisering (vänds …)", and AccrualsStep renders "Rullas
  vidare (ingen vändning)" so the user knows the liability carries forward.

P1 — signature_requests immutability gap (security)
- The existing trigger only guarded role / signer_name / signed_at /
  status on signed rows, leaving bankid_signature_data,
  signer_personnummer_encrypted, and signer_personnummer_hash mutable. An
  UPDATE on a signed row could silently alter the BankID proof. New
  migration 20260517090000 replaces the trigger function to cover the full
  audit-critical column set (plus fiscal_period_id and company_id as
  belt-and-braces).

P1 — narrative edits never reached the PDF
- The /bookkeeping/year-end/arsredovisning page let the user edit
  description / important_events / resultatdisposition but the download
  link pointed at a plain GET that regenerated boilerplate. Wired the PDF
  endpoint to accept description / events / disposition as query params
  (length-capped) and the page now constructs the download URL with the
  current narrative state — only fields the user actually changed are
  included, keeping the URL short for the unchanged-defaults case.

Compliance quick wins
- Added period_lock check to gnubok_post_annual_depreciation MCP tool
  (matches the existing accruals POST guard).
- Added explicit fiscal-period ownership pre-check to the signatures POST
  route (RLS would reject anyway; the route layer just makes the 404
  envelope cleaner).
- Replaced free-text role on the signatures schema with an enum allowing
  only Styrelseledamot / Styrelseordförande / VD / Verkställande direktör.
- Added Cache-Control: no-store + Pragma: no-cache to the ÅR PDF response
  so the document (officer names + financials = personal data) isn't
  cached by any intermediary.
- Sanitized period_end in the PDF Content-Disposition header to dodge
  header-injection via stray chars (defensive — period_end is a date, but
  the cost is one regex).
- Softened the iXBRL warning text on the ÅR page: digital filing is
  proposed by Bolagsverket but not yet enacted; PDF is still valid today.

False positives I'm intentionally not chasing on this round
- Greptile P2 `sate` typo in the address city field — the rename would
  touch the type and every consumer; defer.
- Greptile P2 "computation field label" — already addressed as part of
  the P1 vacation fix above.
- Compliance V2.2 "silent skip" in accruals POST — the silent skip is in
  the UI's empty-row filter, not in the server route. Server validation
  already returns 400 via Zod for any invalid item.

Verification
- 154 unit tests pass (was 149; +5 from re-running the MCP strict-schemas
  + output-schema suites that now include the new tools)
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517090000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #509 round-2 — 3 real bugs + 5-item polish

3 real bugs from the round-1 Swedish review + Compliance Swarm re-eval:

- Soliditet inflated by obeskattade reserver. build-data.ts filtered equity
  with startsWith('20') || startsWith('21') — but 21xx (periodiseringsfonder,
  överavskrivningar) are partially deferred tax, not eget kapital. Splitting
  them out per K2 / ÅRL. Filter is now startsWith('20') only.

- Resultaträkning omitted bokslutsdispositioner + skatt rows. K2 RR must
  include 88xx (dispositioner) and 89xx (skatt) before "Årets resultat" per
  ÅRL 3:2 — without them, the printed RR doesn't reconcile to BS 2099 and
  the document is non-compliant for any AB that posted bolagsskatt or
  periodiseringsfond. flattenIncomeStatement now splits the financial
  sections on title (Bokslutsdispositioner / Skatter och årets resultat)
  and emits the K2-required intermediate subtotals: "Resultat efter
  finansiella poster" → dispositioner → "Resultat före skatt" → skatt →
  "Årets resultat".

- Accruals POST had no idempotency. Re-running the wizard (or a retried
  POST after a flaky network) would create duplicate accrual entries that
  distort both the balance sheet and trial balance. New
  findExistingAccrualEntry helper queries the period for an existing
  posted entry whose description matches the kind's stable prefix (or for
  manual prepaid/accrued, the user-supplied description). Duplicates land
  in a new `skipped` array in the response with a reference to the
  existing entry id, rather than producing a second posting.

5-item compliance polish:

- signer_name on the signatures schema now has .max(200) per GDPR Art.25.2
  data-minimization. Swedish personal names are well under that — the
  bound is a defense against an unbounded-string injection.

- Audit-fee accrual: 6420 is BAS-specific to lagstadgad revision. Bokslut
  fees for a non-revisionspliktigt bolag (liability_account = 2991) now
  debit 6590 (övriga externa tjänster) instead — Skatteverket may query a
  6420 debit when there is no revisor i bolaget.

- Räntefördelning ne_ruta label: was 'R30 / INK1 kapital' (confusing —
  INK1 is a separate form). Positive now reads 'R30 (avdrag i
  näringsverksamhet)' with the INK1 T4 cross-reference moved to the
  description; negative reads 'R30 (tillägg till resultat)'.

- gnubok_post_annual_depreciation MCP tool now checks the caller's
  company_members role and throws on viewer. RLS would reject the
  underlying INSERT anyway; failing fast here produces a cleaner error
  than the cascaded RLS rejection. Mirrors the HTTP route's
  { requireWrite: true } guard.

- Signature DELETE policy now blocks both 'signed' AND 'declined' rows. A
  declined signature is auditable evidence (board member refused) and is
  material under ABL 8 kap. New migration 20260517100000.

Bot-flagged items I'm deliberately not chasing on this round:
- V8.2.1 × 2 cross-tenant findings on ÅR routes — same false-positive
  class I've responded to repeatedly: buildArsredovisningData internally
  filters by company_id, bot can't see past the route handler.
- V4.5 / V2.2 MCP arg Zod redundancy — MCP server's central handler
  validates against each tool's inputSchema.
- Narrative-in-URL GDPR concerns — proper fix is POST + body or
  server-side persistence; tracked as follow-up.
- `sate` typo (Greptile P2) — type-wide rename, deferred.
- Vacation avgifter age-tier rate split — inherited from upstream
  generateVacationLiability which doesn't expose age; needs upstream
  work.
- Medelantal anställda proper monthly average — needs salary-run
  aggregation across the year, follow-up.
- K2 noter aktiekapital + fastställelseintyg blocks — real K2 gaps,
  tracked as follow-up.

Verification
- 94 tests pass (subset for bokslut + MCP suites; full Phase 1-7 suite
  unchanged in scope)
- Zero typecheck errors on any touched file
- Zero new lint errors on any touched file (the 2 server.ts warnings are
  pre-existing)
- Migration 20260517100000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:50:48 +02:00

6779 lines
268 KiB
TypeScript
Raw Blame History

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