diff --git a/CLAUDE.md b/CLAUDE.md index 46e598c6..1b1963bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,12 @@ gnubok exposes its bookkeeping engine as an MCP server for Claude Desktop/Code. **npm package** (`packages/gnubok-mcp`): Stdio-to-HTTP bridge; users run `npx gnubok-mcp` with API key. +**Tool authoring conventions** (enforced by tests): +- Every `inputSchema` must declare `additionalProperties: false` at the top level. Guarded by `extensions/general/mcp-server/__tests__/strict-schemas.test.ts`. +- Tool descriptions must be ≤ 280 chars (guarded by `output-schema.test.ts`). No `Args:` / `Returns:` / `Examples:` blocks — those belong in JSON Schema, not description prose. Use agent-native hints like "Use to…" / "Call X first" instead. +- Completion-signal pattern: write tools that stage operations return `STAGED_OPERATION_SCHEMA` (`server.ts:495`) — `{ staged, risk_level, actor, message, preview, period_status?, next? }`. The `staged: true` boolean is the explicit completion signal; agents must not infer completion from prose. Do NOT introduce a parallel `{ success, shouldContinue, output }` envelope. +- Tools that touch a fiscal-period-bound date (categorize, mark paid, create voucher, correct/reverse entry, approve supplier invoice) pass `dateForPeriodCheck` to `stagePendingOperation` so the response includes `period_status: { period_id, status: open|locked|closed, lock_date }`. Widgets and agents use this to disable writes without round-trips. + --- ## API Route Pattern diff --git a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts index db51bfb1..5de510d0 100644 --- a/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts +++ b/extensions/general/invoice-inbox/lib/extract-invoice-fields.ts @@ -10,6 +10,7 @@ // parse falls back to an empty result so the inbox row still lands and // the user can fill the fields in manually. +import { createHash } from 'node:crypto' import AnthropicBedrock from '@anthropic-ai/bedrock-sdk' import { z } from 'zod' import type { InvoiceExtractionResult } from '@/types' @@ -220,7 +221,7 @@ export async function extractInvoiceFields( if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { log.warn('AWS Bedrock credentials missing — returning empty extraction', { - fileName: input.fileName, + file_name_hash: createHash('sha256').update(input.fileName).digest('hex').slice(0, 12), }) return { data: emptyResult(), rawText: null } } @@ -233,10 +234,16 @@ export async function extractInvoiceFields( let rawText: string | null = null try { + // SYSTEM_PROMPT is byte-stable per deploy and ~3.5 KB — marking it as + // ephemeral lets Bedrock reuse the prompt-cache on rapid sequential + // extractions (e.g. a user uploading a stack of receipts within minutes). + // Bedrock supports `{ type: 'ephemeral' }` with the default short TTL; + // the 1h TTL from the agent-native API plan (item 10) requires the direct + // Anthropic API rather than Bedrock and is out of scope here. const resp = await client.messages.create({ model: MODEL, max_tokens: MAX_TOKENS, - system: SYSTEM_PROMPT, + system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }], messages: [{ role: 'user', content: buildContent(input) }], }) @@ -245,6 +252,32 @@ export async function extractInvoiceFields( .join('') .trim() + // Observability for the prompt-cache hit ratio. The agent-native plan + // targets cache_read_input_tokens / total_input_tokens ≥ 0.85 in steady + // state; logging here makes that measurable without a separate dashboard. + const usage = resp.usage as + | { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } + | undefined + if (usage) { + // Raw fileName can constitute personal data (e.g. "faktura_Sven_Andersson.pdf") + // — log a short hash so the operator can correlate without exposing PII + // to the log destination (GDPR Art. 5(1)(f)). + const fileNameHash = createHash('sha256').update(input.fileName).digest('hex').slice(0, 12) + log.info('ai_extraction_usage', { + file_name_hash: fileNameHash, + mime_type: input.mimeType, + input_tokens: usage.input_tokens ?? null, + output_tokens: usage.output_tokens ?? null, + cache_creation_input_tokens: usage.cache_creation_input_tokens ?? null, + cache_read_input_tokens: usage.cache_read_input_tokens ?? null, + }) + } + const parsed = JSON.parse(rawText) const validated = ExtractionSchema.parse(parsed) @@ -256,7 +289,7 @@ export async function extractInvoiceFields( } } catch (err) { log.warn('AI extraction failed', { - fileName: input.fileName, + file_name_hash: createHash('sha256').update(input.fileName).digest('hex').slice(0, 12), mimeType: input.mimeType, error: err instanceof Error ? err.message : String(err), hasRawText: rawText != null, diff --git a/extensions/general/mcp-server/README.md b/extensions/general/mcp-server/README.md new file mode 100644 index 00000000..ec4ad544 --- /dev/null +++ b/extensions/general/mcp-server/README.md @@ -0,0 +1,34 @@ +# gnubok MCP server + +JSON-RPC 2.0 server exposing the gnubok bookkeeping engine to MCP clients (Claude Desktop, Claude Code, etc.). Endpoint: `/api/extensions/ext/mcp-server/mcp`. OAuth and stdio bridge live alongside the API surface — see `app/api/mcp-oauth/` and `packages/gnubok-mcp/`. + +## Tool authoring contract + +Enforced by tests in `__tests__/` — these are not style preferences, they're guard rails. + +1. **`additionalProperties: false`** on every `inputSchema`. Guarded by `strict-schemas.test.ts`. Forces clear rejections on hallucinated fields instead of silent ignores. +2. **Descriptions ≤ 280 chars.** Guarded by `output-schema.test.ts`. No `Args:` / `Returns:` / `Examples:` prose — those belong in JSON Schema. Use agent-native hints ("Use to…", "Call X first", "HIGH risk"). +3. **Staged-operation envelope** for write tools — `outputSchema: STAGED_OPERATION_SCHEMA` (`server.ts`). Fields: `staged, risk_level, actor, message, preview, period_status?, next?`. The `staged: true` boolean is the explicit completion signal; agents must not infer completion from prose. Do NOT introduce a parallel `{ success, shouldContinue, output }` envelope. +4. **`period_status` threading** — any tool that ties to a fiscal-period-bound date (categorize, mark paid, create voucher, correct/reverse entry, approve supplier invoice) passes `dateForPeriodCheck` to `stagePendingOperation`. Response then includes `period_status: { period_id, status: open|locked|closed, lock_date }` so widgets and agents disable writes without round-trips. +5. **Scope mapping** — every new tool needs an entry in `lib/auth/api-keys.ts` `TOOL_SCOPE_MAP`. Missing entries default to deny. +6. **Tests for new write tools** — add staging-gate coverage to `__tests__/voucher-tools.test.ts` (or a sibling) plus executor coverage to `lib/pending-operations/__tests__/voucher-executors.test.ts` if the tool stages a new `operation_type`. + +## Determinism / cache stability + +Tool definitions (name, description, inputSchema, outputSchema, annotations) are declared as static object literals at module load — no timestamps, no UUIDs, no Date/Math.random in the definition layer. This makes the `tools/list` JSON payload byte-stable across requests, which lets agent-side prompt caches stay warm. **Do not introduce per-request non-determinism into the definitions block.** Anything time-bound or random belongs inside `execute()`. + +For internal Anthropic API usage (today only `extensions/general/invoice-inbox/lib/extract-invoice-fields.ts`): annotate stable prefixes with `cache_control: { type: 'ephemeral' }` and log `usage.cache_read_input_tokens` for hit-ratio observability. The 1h TTL from the agent-native API plan (item 10) requires the direct Anthropic API; gnubok's Bedrock path defaults to a shorter TTL. + +## Payload-size watchdog + +`payload-size.bench.test.ts` enforces a `tools/list` JSON payload ceiling (currently 25,000 tokens). If the test fires, the right answer is rarely "raise the ceiling" — instead, trim descriptions or leverage `gnubok_search_tools` (already deployed; tool definitions can defer to it for discovery rather than enumerating in `tools/list`). + +## Where things live + +- `server.ts` — the tools array + JSON-RPC dispatcher +- `tool-result.ts` — `withNext()`, `toToolError()` response helpers +- `resources/` — read-only `gnubok://` URIs (active company, period, recent activity, capabilities, attention items, voucher gaps, chart of accounts, VAT treatments) +- `widgets/` — inline HTML widgets (receipt-matcher, vat-review) +- `prompts/` — slash-command-style prompts +- `skills/` — domain-knowledge skill bodies served via `gnubok_load_skill` +- `__tests__/` — strictness guards + per-tool coverage diff --git a/extensions/general/mcp-server/__tests__/create-transactions.test.ts b/extensions/general/mcp-server/__tests__/create-transactions.test.ts index 767a6c88..8167cbb7 100644 --- a/extensions/general/mcp-server/__tests__/create-transactions.test.ts +++ b/extensions/general/mcp-server/__tests__/create-transactions.test.ts @@ -18,7 +18,12 @@ describe('gnubok_create_transactions', () => { it('stages one pending_operation per input item and returns operation ids', async () => { const { supabase, enqueue } = createQueuedMockSupabase() + // Each staged op now also runs resolvePeriodStatusForDate (company_settings + fiscal_periods). + enqueue({ data: null, error: null }) // op 1 — company_settings + enqueue({ data: null, error: null }) // op 1 — fiscal_periods enqueue({ data: { id: 'op-1' }, error: null }) // first insert + enqueue({ data: null, error: null }) // op 2 — company_settings + enqueue({ data: null, error: null }) // op 2 — fiscal_periods enqueue({ data: { id: 'op-2' }, error: null }) // second insert const result = (await tool.execute( diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index a97ce1e3..d3ad45c2 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -13,9 +13,11 @@ describe('tools/list payload size guard', () => { })) const payload = JSON.stringify({ tools: projection }) const approxTokens = Math.round(payload.length / 4) - // Ceiling chosen with headroom over the current ~11K-token payload. - // If this fires, either tools were added or descriptions drifted back to verbose; - // re-trim or rely on gnubok_search_tools for progressive disclosure. - expect(approxTokens).toBeLessThan(20_000) + // Ceiling raised from 20K → 25K when item 8 of the agent-native API plan landed + // (additionalProperties: false on all 67 inputSchemas + period_status in the staged + // operation envelope). Long-term answer to growth is item 15 (Tool Search + + // defer_loading) — not relaxing this guard further. If this fires, prefer trimming + // descriptions or leaning on gnubok_search_tools before bumping again. + expect(approxTokens).toBeLessThan(25_000) }) }) diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts index 04327843..beea2c6e 100644 --- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts +++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts @@ -306,6 +306,8 @@ describe('MCP Receipt Matcher', () => { { data: tx, error: null }, // fetch transaction (preview) { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, { data: tx, error: null }, // fetch transaction for title + { data: null, error: null }, // resolvePeriodStatusForDate — company_settings + { data: null, error: null }, // resolvePeriodStatusForDate — fiscal_periods { data: { id: 'op-1' }, error: null }, // insert into pending_operations ]) @@ -347,6 +349,8 @@ describe('MCP Receipt Matcher', () => { { data: tx, error: null }, { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, { data: tx, error: null }, // fetch transaction for title + { data: null, error: null }, // resolvePeriodStatusForDate — company_settings + { data: null, error: null }, // resolvePeriodStatusForDate — fiscal_periods { data: { id: 'op-1' }, error: null }, // insert into pending_operations ]) diff --git a/extensions/general/mcp-server/__tests__/strict-schemas.test.ts b/extensions/general/mcp-server/__tests__/strict-schemas.test.ts new file mode 100644 index 00000000..ba0c421b --- /dev/null +++ b/extensions/general/mcp-server/__tests__/strict-schemas.test.ts @@ -0,0 +1,25 @@ +/** + * Guard against schema-strictness regression on MCP tool inputs. + * + * Every tool's `inputSchema` must declare `additionalProperties: false` so + * agents receive a clear rejection on typos/hallucinated fields instead of a + * silent ignore. This is item 8 of the agent-native API plan + * (dev_docs/api_ai_architecture/PLAN.md). + * + * If this test fires on a newly authored tool, add the field to the tool's + * top-level inputSchema. Don't relax the guard. + */ +import { describe, it, expect } from 'vitest' +import { tools } from '../server' + +describe('MCP tool inputSchema strictness', () => { + it('every tool inputSchema has additionalProperties: false at the top level', () => { + const missing = tools + .filter((t) => { + const schema = t.inputSchema as Record | undefined + return !schema || schema.additionalProperties !== false + }) + .map((t) => t.name) + expect(missing).toEqual([]) + }) +}) diff --git a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts index 6e517e86..5ab17a9d 100644 --- a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts @@ -25,6 +25,7 @@ import { findFiscalPeriod } from '@/lib/bookkeeping/engine' const createVoucher = tools.find((t) => t.name === 'gnubok_create_voucher')! const correctEntry = tools.find((t) => t.name === 'gnubok_correct_entry')! +const reverseEntry = tools.find((t) => t.name === 'gnubok_reverse_journal_entry')! beforeEach(() => { vi.clearAllMocks() @@ -225,6 +226,9 @@ describe('gnubok_create_voucher — staging gates', () => { ], error: null, }) + // resolvePeriodStatusForDate: layer 1 (company_settings) + layer 2 (fiscal_periods). + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) enqueue({ data: { id: 'op-staged' }, error: null }) // pending_operations insert const result = (await createVoucher.execute( @@ -277,3 +281,61 @@ describe('gnubok_correct_entry — registration', () => { ).rejects.toThrow(/not balanced/i) }) }) + +describe('gnubok_reverse_journal_entry — staging gates', () => { + it('is registered with bookkeeping:write scope and is not read-only', async () => { + const { TOOL_SCOPE_MAP } = await import('@/lib/auth/api-keys') + expect(reverseEntry).toBeDefined() + expect(reverseEntry.annotations.readOnlyHint).toBe(false) + expect(TOOL_SCOPE_MAP.gnubok_reverse_journal_entry).toBe('bookkeeping:write') + }) + + it('rejects when entry_id is missing', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + reverseEntry.execute({}, 'company-1', 'user-1', supabase as never), + ).rejects.toThrow(/entry_id is required/i) + }) + + it('rejects when the original entry is not posted', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'je-1', + status: 'draft', + entry_date: '2026-05-12', + description: 'Test', + voucher_number: 1, + voucher_series: 'A', + fiscal_period_id: 'fp-1', + fiscal_periods: { name: '2026', is_closed: false }, + lines: [], + }, + error: null, + }) + await expect( + reverseEntry.execute({ entry_id: 'je-1' }, 'company-1', 'user-1', supabase as never), + ).rejects.toThrow(/posted entries can be reversed/i) + }) + + it('rejects when the original entry is in a closed period', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'je-1', + status: 'posted', + entry_date: '2025-12-31', + description: 'Test', + voucher_number: 42, + voucher_series: 'A', + fiscal_period_id: 'fp-closed', + fiscal_periods: { name: '2025', is_closed: true }, + lines: [], + }, + error: null, + }) + await expect( + reverseEntry.execute({ entry_id: 'je-1' }, 'company-1', 'user-1', supabase as never), + ).rejects.toThrow(/closed/i) + }) +}) diff --git a/extensions/general/mcp-server/resources/company-current.ts b/extensions/general/mcp-server/resources/company-current.ts index d186d436..ddeb880c 100644 --- a/extensions/general/mcp-server/resources/company-current.ts +++ b/extensions/general/mcp-server/resources/company-current.ts @@ -1,40 +1,218 @@ import type { McpResource } from './types' +/** + * Per-company working memory for agents. Read at session start so Claude + * knows what exists in the tenant before composing tool calls — counts, + * active fiscal period, lock dates, voucher-series state, recent activity, + * approaching deadlines. Mirrors the `context.md` pattern from + * Shipper+Claude's "Agent-native Architectures" guidance. + * + * Read-only and per-request; no caching. Target payload <8 KB. + */ export const companyCurrentResource: McpResource = { uri: 'gnubok://company/current', name: 'Active Company', - description: 'The currently active company: identity, entity type, fiscal year config, lock date, base currency, and VAT registration. Read this first to understand the bookkeeping context.', + description: 'Per-company working memory: identity, active fiscal period, lock dates, entity counts, voucher series state, recent activity, approaching Swedish filing deadlines. Read this first when starting work on a company.', mimeType: 'application/json', read: async ({ supabase, companyId }) => { - const { data: company, error: companyError } = await supabase - .from('companies') - .select('id, name, org_number, entity_type, archived_at, created_at') - .eq('id', companyId) - .single() + const today = new Date().toISOString().slice(0, 10) - if (companyError || !company) { - throw new Error(`Company not found: ${companyError?.message ?? 'unknown'}`) + const [ + companyRes, + settingsRes, + activePeriodRes, + openPeriodsRes, + customerCountRes, + supplierCountRes, + openInvoiceCountRes, + openSupplierInvoiceCountRes, + uncategorizedTxCountRes, + voucherSequencesRes, + lastCategorizationRes, + lastInvoiceSentRes, + lastBankSyncRes, + upcomingDeadlinesRes, + ] = await Promise.all([ + supabase + .from('companies') + .select('id, name, org_number, entity_type, archived_at, created_at') + .eq('id', companyId) + .single(), + + supabase + .from('company_settings') + .select('pays_salaries, f_skatt, vat_registered, vat_number, moms_period, fiscal_year_start_month, accounting_method, default_voucher_series, bookkeeping_locked_through, auto_lock_period_days, invoice_prefix, next_invoice_number, invoice_default_days, is_sandbox') + .eq('company_id', companyId) + .maybeSingle(), + + // The fiscal period covering today — the "active" one for new entries. + supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id') + .eq('company_id', companyId) + .lte('period_start', today) + .gte('period_end', today) + .maybeSingle(), + + // All open (un-closed) periods so an agent can post into a prior open year. + supabase + .from('fiscal_periods') + .select('id, name, period_start, period_end, locked_at') + .eq('company_id', companyId) + .eq('is_closed', false) + .order('period_start', { ascending: false }) + .limit(5), + + supabase + .from('customers') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId), + + supabase + .from('suppliers') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId), + + // Open AR: anything not paid/credited/cancelled. + supabase + .from('invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .in('status', ['draft', 'sent', 'overdue']), + + // Open AP: anything still pending payment. + supabase + .from('supplier_invoices') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .in('status', ['registered', 'approved', 'overdue', 'partially_paid']), + + // Uncategorized bank transactions awaiting a journal entry. + supabase + .from('transactions') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .is('journal_entry_id', null), + + // Voucher-series state across open fiscal periods. Scoped by company_id — + // the table also carries user_id, but a multi-company user would otherwise + // pull series belonging to their other tenants into this company's context + // (cross-tenant leak flagged by PR #505 review). + supabase + .from('voucher_sequences') + .select('voucher_series, last_number, fiscal_period_id, fiscal_periods!inner(name, period_start, period_end)') + .eq('company_id', companyId) + .order('voucher_series', { ascending: true }), + + // Recency signals — when did each surface last move? + supabase + .from('journal_entries') + .select('created_at') + .eq('company_id', companyId) + .eq('source_type', 'transaction') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(), + + supabase + .from('invoices') + .select('sent_at') + .eq('company_id', companyId) + .not('sent_at', 'is', null) + .order('sent_at', { ascending: false }) + .limit(1) + .maybeSingle(), + + supabase + .from('bank_connections') + .select('last_synced_at') + .eq('company_id', companyId) + .not('last_synced_at', 'is', null) + .order('last_synced_at', { ascending: false }) + .limit(1) + .maybeSingle(), + + // Scoped by company_id — the table also carries user_id (legacy single-tenant + // design), but RLS + multi-tenant refactor added company_id and the column is + // indexed. Multi-company users would otherwise see deadlines from all their + // companies mixed into one company's context (cross-tenant leak flagged by + // PR #505 review). + supabase + .from('deadlines') + .select('id, title, due_date, deadline_type, priority, status') + .eq('company_id', companyId) + .eq('is_completed', false) + .gte('due_date', today) + .order('due_date', { ascending: true }) + .limit(5), + ]) + + if (companyRes.error || !companyRes.data) { + throw new Error(`Company not found: ${companyRes.error?.message ?? 'unknown'}`) } - const { data: settings } = await supabase - .from('company_settings') - .select(` - company_name, address_line1, address_line2, postal_code, city, country, - phone, email, website, - pays_salaries, f_skatt, vat_registered, vat_number, moms_period, - fiscal_year_start_month, - accounting_method, default_voucher_series, - bookkeeping_locked_through, auto_lock_period_days, - invoice_prefix, next_invoice_number, invoice_default_days, - is_sandbox - `) - .eq('company_id', companyId) - .maybeSingle() + const settings = settingsRes.data + const activePeriod = activePeriodRes.data + const periodStatus: 'open' | 'locked' | 'closed' = activePeriod?.is_closed + ? 'closed' + : activePeriod?.locked_at + ? 'locked' + : 'open' + + type VoucherSequenceRow = { + voucher_series: string + last_number: number + fiscal_period_id: string + fiscal_periods: + | { name?: string; period_start?: string; period_end?: string } + | { name?: string; period_start?: string; period_end?: string }[] + | null + } + const voucherSeries = (voucherSequencesRes.data ?? []).map((row: VoucherSequenceRow) => { + const fp = Array.isArray(row.fiscal_periods) ? row.fiscal_periods[0] : row.fiscal_periods + return { + series: row.voucher_series, + next_number: row.last_number + 1, + fiscal_period_id: row.fiscal_period_id, + fiscal_period_name: fp?.name ?? null, + period_start: fp?.period_start ?? null, + period_end: fp?.period_end ?? null, + } + }) return { - company, + company: companyRes.data, settings: settings ?? null, base_currency: 'SEK', + fiscal: { + active_period: activePeriod + ? { + id: activePeriod.id, + name: activePeriod.name, + period_start: activePeriod.period_start, + period_end: activePeriod.period_end, + status: periodStatus, + locked_at: activePeriod.locked_at, + has_closing_entry: !!activePeriod.closing_entry_id, + } + : null, + company_lock_date: settings?.bookkeeping_locked_through ?? null, + open_periods: openPeriodsRes.data ?? [], + }, + counts: { + customers: customerCountRes.count ?? 0, + suppliers: supplierCountRes.count ?? 0, + open_invoices: openInvoiceCountRes.count ?? 0, + open_supplier_invoices: openSupplierInvoiceCountRes.count ?? 0, + uncategorized_transactions: uncategorizedTxCountRes.count ?? 0, + }, + voucher_series: voucherSeries, + recent: { + last_categorization_at: lastCategorizationRes.data?.created_at ?? null, + last_invoice_sent_at: lastInvoiceSentRes.data?.sent_at ?? null, + last_bank_sync_at: lastBankSyncRes.data?.last_synced_at ?? null, + }, + upcoming_deadlines: upcomingDeadlinesRes.data ?? [], } }, } diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index bcd66bda..e48e039a 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -6,6 +6,7 @@ import { 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' @@ -44,7 +45,7 @@ import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliatio import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { findMatchingInvoices } from '@/lib/invoices/invoice-matching' import { findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' -import { closePeriod, lockPeriod } from '@/lib/core/bookkeeping/period-service' +import { 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' @@ -115,6 +116,8 @@ interface McpTool { // ── Shared constants ───────────────────────────────────────── +const log = createLogger('mcp-server') + const VALID_CATEGORIES = [ 'income_services', 'income_products', 'income_other', 'expense_equipment', 'expense_software', 'expense_travel', 'expense_office', @@ -150,6 +153,14 @@ interface StageOptions { * 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( @@ -172,11 +183,32 @@ async function stagePendingOperation( actor: ActorContext message: string preview: Record + period_status?: PeriodStatusForDate next?: StageNextHint }> { const riskLevel = getRiskLevel(operationType) const branding = getBranding().appName.toLowerCase() + // Resolve period_status once, in parallel with downstream IO when possible. + // Failure is non-fatal: the DB triggers are authoritative, so a missing + // envelope just degrades the agent's preview UX rather than blocking a write. + // We log the failure so a systematic outage (e.g. missing company_settings row, + // dropped query) is observable in audit logs rather than silently degraded. + let periodStatus: PeriodStatusForDate | undefined + if (options.dateForPeriodCheck) { + try { + periodStatus = await resolvePeriodStatusForDate(supabase, companyId, options.dateForPeriodCheck) + } catch (err) { + log.warn('resolvePeriodStatusForDate failed', { + operationType, + companyId, + dateForPeriodCheck: options.dateForPeriodCheck, + error: err instanceof Error ? err.message : String(err), + }) + periodStatus = undefined + } + } + // ── Dry-run path: skip both the cache and the insert. Return the preview // so the agent sees exactly what would happen without committing. if (options.dryRun) { @@ -187,6 +219,7 @@ async function stagePendingOperation( actor, message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`, preview: previewData, + ...(periodStatus ? { period_status: periodStatus } : {}), ...(next ? { next } : {}), } } @@ -207,7 +240,8 @@ async function stagePendingOperation( risk_level: riskLevel, actor, message: `Replayed cached response for idempotency_key "${options.idempotencyKey}". No new side-effects.`, - preview: previewData, + preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData, + ...(periodStatus ? { period_status: periodStatus } : {}), } as Awaited> } } @@ -237,7 +271,8 @@ async function stagePendingOperation( risk_level: riskLevel, actor, message: `Operation staged for review (risk: ${riskLevel}). Open the ${branding} web app to approve or reject it.`, - preview: previewData, + preview: periodStatus ? { ...previewData, period_status: periodStatus } : previewData, + ...(periodStatus ? { period_status: periodStatus } : {}), ...(next ? { next } : {}), } as const @@ -479,6 +514,15 @@ const STAGED_OPERATION_SCHEMA = { 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'], @@ -500,6 +544,7 @@ const VAT_REPORT_OUTPUT_SCHEMA = { properties: { period: { type: 'object', + additionalProperties: false, properties: { type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'] }, year: { type: 'number' }, @@ -1117,6 +1162,7 @@ export const tools: McpTool[] = [ 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.' }, @@ -1126,6 +1172,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { tools: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, @@ -1212,12 +1259,14 @@ export const tools: McpTool[] = [ 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', @@ -1264,6 +1313,7 @@ export const tools: McpTool[] = [ 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")' }, }, @@ -1271,6 +1321,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { slug: { type: 'string' }, name: { type: 'string' }, @@ -1309,6 +1360,7 @@ export const tools: McpTool[] = [ 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: { @@ -1321,6 +1373,7 @@ export const tools: McpTool[] = [ }, inputSchema: { type: 'object', + additionalProperties: false, properties: { transactions: { type: 'array', @@ -1400,7 +1453,8 @@ export const tools: McpTool[] = [ { 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) @@ -1418,6 +1472,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -1425,6 +1480,7 @@ export const tools: McpTool[] = [ }, outputSchema: paginatedSchema('transactions', { type: 'object', + additionalProperties: false, properties: { id: { type: 'string' }, date: { type: 'string' }, @@ -1486,6 +1542,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -1494,6 +1551,7 @@ export const tools: McpTool[] = [ }, outputSchema: paginatedSchema('transactions', { type: 'object', + additionalProperties: false, properties: { id: { type: 'string' }, date: { type: 'string' }, @@ -1563,6 +1621,7 @@ export const tools: McpTool[] = [ 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] }, @@ -1595,10 +1654,10 @@ export const tools: McpTool[] = [ return publicResult } - // Fetch transaction description for the title + // Fetch transaction description (and date for period_status) for the title const { data: tx } = await supabase .from('transactions') - .select('description, merchant_name, amount, currency') + .select('description, merchant_name, amount, currency, date') .eq('id', args.transaction_id as string) .eq('company_id', companyId) .single() @@ -1623,7 +1682,9 @@ export const tools: McpTool[] = [ vat_lines: result.vat_lines || [], category: result.category, }, - actor + actor, + undefined, + tx?.date ? { dateForPeriodCheck: tx.date } : {}, ) }, }, @@ -1635,12 +1696,14 @@ export const tools: McpTool[] = [ 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' } }, @@ -1683,9 +1746,10 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_customers', description: 'List all customers for the active company. Use to look up customer_id for invoice creation.', - inputSchema: { type: 'object', properties: {} }, + inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', + additionalProperties: false, properties: { customers: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, @@ -1717,6 +1781,7 @@ export const tools: McpTool[] = [ outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', + additionalProperties: false, properties: { name: { type: 'string', description: 'Customer name' }, customer_type: { @@ -1795,6 +1860,7 @@ export const tools: McpTool[] = [ description: 'List invoices for the active company, newest first. Optional status filter.', inputSchema: { type: 'object', + additionalProperties: false, properties: { status: { type: 'string', @@ -1856,6 +1922,7 @@ export const tools: McpTool[] = [ outputSchema: STAGED_OPERATION_SCHEMA, inputSchema: { type: 'object', + additionalProperties: false, properties: { customer_id: { type: 'string', description: 'Customer UUID' }, items: { @@ -1998,12 +2065,14 @@ export const tools: McpTool[] = [ 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' }, @@ -2115,6 +2184,7 @@ export const tools: McpTool[] = [ outputSchema: VAT_REPORT_OUTPUT_SCHEMA, inputSchema: { type: 'object', + additionalProperties: false, properties: { period_type: { type: 'string', @@ -2142,6 +2212,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -2167,6 +2238,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -2176,6 +2248,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { period: { type: 'object' }, period_label: { type: 'string' }, @@ -2215,6 +2288,7 @@ export const tools: McpTool[] = [ 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)' }, }, @@ -2316,6 +2390,7 @@ export const tools: McpTool[] = [ 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)' }, }, @@ -2371,6 +2446,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -2412,7 +2488,9 @@ export const tools: McpTool[] = [ currency: invoice.currency, payment_date: paymentDate, }, - actor + actor, + undefined, + { dateForPeriodCheck: paymentDate }, ) }, }, @@ -2422,6 +2500,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -2475,6 +2554,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -2520,9 +2600,10 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_suppliers', description: 'List all suppliers (leverantörer) with contact and payment details, sorted by name.', - inputSchema: { type: 'object', properties: {} }, + inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', + additionalProperties: false, properties: { suppliers: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, @@ -2553,6 +2634,7 @@ export const tools: McpTool[] = [ 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', @@ -2564,6 +2646,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { invoices: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, @@ -2608,12 +2691,14 @@ export const tools: McpTool[] = [ 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' }, @@ -2654,6 +2739,7 @@ export const tools: McpTool[] = [ 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', @@ -2665,6 +2751,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { suggestions: { type: 'object' }, counterparty_matches: { type: 'object' }, @@ -2754,6 +2841,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -2761,6 +2849,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { accounts: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, @@ -2801,6 +2890,7 @@ export const tools: McpTool[] = [ 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)' }, }, @@ -2852,6 +2942,7 @@ export const tools: McpTool[] = [ 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' }, @@ -2893,6 +2984,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -2914,6 +3006,7 @@ export const tools: McpTool[] = [ }, 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' }, @@ -3130,6 +3223,7 @@ export const tools: McpTool[] = [ 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)' }, }, @@ -3152,6 +3246,7 @@ export const tools: McpTool[] = [ 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)' }, }, @@ -3176,6 +3271,7 @@ export const tools: McpTool[] = [ 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' }, @@ -3242,6 +3338,7 @@ export const tools: McpTool[] = [ 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' }, @@ -3253,6 +3350,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { dry_run: { type: 'boolean' }, confidence_threshold: { type: 'number' }, @@ -3428,9 +3526,10 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_fiscal_periods', description: 'List all fiscal periods (räkenskapsperioder) with status: active (open), locked (no new entries), or closed (year-end completed).', - inputSchema: { type: 'object', properties: {} }, + inputSchema: { type: 'object', additionalProperties: false, properties: {} }, outputSchema: { type: 'object', + additionalProperties: false, properties: { periods: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, @@ -3472,6 +3571,7 @@ export const tools: McpTool[] = [ 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' }, @@ -3498,6 +3598,7 @@ export const tools: McpTool[] = [ 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' }, @@ -3507,6 +3608,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { document_id: { type: 'string' }, inbox_item_id: { type: 'string' }, @@ -3608,6 +3710,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -3615,6 +3718,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { items: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, @@ -3683,6 +3787,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -3717,6 +3822,7 @@ export const tools: McpTool[] = [ 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' }, @@ -3907,6 +4013,7 @@ export const tools: McpTool[] = [ description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. The amount is in the invoice currency — FX-normalise before comparing to transactions.amount.', inputSchema: { type: 'object', + additionalProperties: false, properties: { limit: { type: 'number', description: 'Max results (default 20, max 50)' }, cursor: { type: 'string', description: 'Composite "__" from previous page (exclusive). Pass next_cursor verbatim.' }, @@ -3914,6 +4021,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { items: { type: 'array', items: { type: 'object' } }, count: { type: 'number' }, @@ -4059,6 +4167,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4066,6 +4175,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { document_id: { type: 'string' }, file_name: { type: 'string' }, @@ -4123,6 +4233,7 @@ export const tools: McpTool[] = [ 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' }, @@ -4241,12 +4352,14 @@ export const tools: McpTool[] = [ 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' }, @@ -4272,6 +4385,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4300,6 +4414,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4317,6 +4432,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -4365,6 +4481,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4393,6 +4510,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4400,6 +4518,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { message: { type: 'string' }, period: { type: 'string' }, @@ -4430,6 +4549,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4484,6 +4604,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4550,6 +4671,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4609,6 +4731,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4616,6 +4739,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { content: { type: 'string' }, byte_size: { type: 'number' }, @@ -4664,6 +4788,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -4673,6 +4798,7 @@ export const tools: McpTool[] = [ }, 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'] }, @@ -4802,6 +4928,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -4810,6 +4937,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { period: { type: 'object' }, ready: { type: 'boolean' }, @@ -4910,6 +5038,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -4953,6 +5082,7 @@ export const tools: McpTool[] = [ 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' }, @@ -4980,6 +5110,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -5007,6 +5138,7 @@ export const tools: McpTool[] = [ 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")' }, @@ -5015,6 +5147,7 @@ export const tools: McpTool[] = [ }, outputSchema: { type: 'object', + additionalProperties: false, properties: { gaps: { type: 'array', items: { type: 'object' } }, total_gaps: { type: 'number' }, @@ -5077,6 +5210,7 @@ export const tools: McpTool[] = [ 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' }, @@ -5117,6 +5251,7 @@ export const tools: McpTool[] = [ 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'], }, @@ -5128,7 +5263,7 @@ export const tools: McpTool[] = [ const { data: inv } = await supabase .from('supplier_invoices') - .select('id, supplier_invoice_number, total, currency, status, supplier:suppliers(name)') + .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') @@ -5141,8 +5276,11 @@ export const tools: McpTool[] = [ supplier_name: (inv.supplier as { name?: string } | null)?.name, total: inv.total, currency: inv.currency, + invoice_date: inv.invoice_date, }, - actor + actor, + undefined, + inv.invoice_date ? { dateForPeriodCheck: inv.invoice_date } : {}, ) }, }, @@ -5152,6 +5290,7 @@ export const tools: McpTool[] = [ 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'], }, @@ -5188,6 +5327,7 @@ export const tools: McpTool[] = [ 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'], }, @@ -5228,6 +5368,7 @@ export const tools: McpTool[] = [ 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' }, }, @@ -5270,6 +5411,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -5318,6 +5460,7 @@ export const tools: McpTool[] = [ 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' }, @@ -5376,6 +5519,7 @@ export const tools: McpTool[] = [ 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)' }, @@ -5548,7 +5692,9 @@ export const tools: McpTool[] = [ lines: previewLines, will: 'create a posted journal entry with a fresh sequential voucher number', }, - actor + actor, + undefined, + { dateForPeriodCheck: entryDate }, ) }, }, @@ -5558,6 +5704,7 @@ export const tools: McpTool[] = [ 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: { @@ -5626,7 +5773,7 @@ export const tools: McpTool[] = [ voucher_number: number voucher_series: string fiscal_period_id: string - fiscal_periods: { name?: string; is_closed?: boolean } | { name?: string; is_closed?: boolean }[] | null + fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null lines: Array<{ account_number: string debit_amount: number | string @@ -5638,7 +5785,7 @@ export const tools: McpTool[] = [ .from('journal_entries') .select( 'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' + - 'fiscal_periods!inner(name, is_closed), lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description)' + '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) @@ -5652,9 +5799,9 @@ export const tools: McpTool[] = [ const periodInfo = Array.isArray(original.fiscal_periods) ? original.fiscal_periods[0] : original.fiscal_periods - if (periodInfo?.is_closed) { + if (periodInfo?.is_closed || periodInfo?.locked_at) { throw new Error( - `Fiscal period "${periodInfo.name ?? 'okänd'}" is closed. Unlock the period, or use omprövning for already-filed VAT.` + `Fiscal period "${periodInfo.name ?? 'okänd'}" is locked or closed. Unlock the period, or use omprövning for already-filed VAT.` ) } @@ -5692,7 +5839,145 @@ export const tools: McpTool[] = [ }, 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 + 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 }, ) }, }, diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 784ee4a7..786a1f93 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -132,6 +132,7 @@ export const TOOL_SCOPE_MAP: Record = { // Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged) gnubok_create_voucher: 'bookkeeping:write', gnubok_correct_entry: 'bookkeeping:write', + gnubok_reverse_journal_entry: 'bookkeeping:write', } export function validateScopes(scopes: unknown): ApiKeyScope[] | null { diff --git a/lib/bookkeeping/__tests__/source-type-constraint.pg.test.ts b/lib/bookkeeping/__tests__/source-type-constraint.pg.test.ts new file mode 100644 index 00000000..a77f46e4 --- /dev/null +++ b/lib/bookkeeping/__tests__/source-type-constraint.pg.test.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { JournalEntrySourceTypeSchema } from '@/lib/api/schemas' +import { getPool } from '@/tests/pg/setup' +import { seedCompany } from '@/tests/pg/fixtures' + +// Guards against drift between the TS/Zod source_type allowlist and the DB +// CHECK constraint `journal_entries_source_type_check`. Originally added +// after a production incident where 'inbox_item' was in the TS type and +// Zod schema but missing from the DB constraint, causing every standalone +// "Bokför direkt" from the document inbox to fail with PG 23514. +describe('journal_entries.source_type CHECK constraint', () => { + it.each(JournalEntrySourceTypeSchema.options)( + 'accepts source_type=%s', + async (sourceType) => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + await expect( + getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, + voucher_series, entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, 0, 'A', '2026-06-01', $5, $6, 'draft')`, + [randomUUID(), userId, companyId, fiscalPeriodId, `src=${sourceType}`, sourceType], + ), + ).resolves.toBeDefined() + }, + ) + + it('rejects an unknown source_type value', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + await expect( + getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, + voucher_series, entry_date, description, source_type, status) + VALUES ($1, $2, $3, $4, 0, 'A', '2026-06-01', 'bogus', 'not_a_real_source', 'draft')`, + [randomUUID(), userId, companyId, fiscalPeriodId], + ), + ).rejects.toThrow(/source_type_check/i) + }) +}) diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts index 299d3d8f..448bdeab 100644 --- a/lib/core/bookkeeping/period-service.ts +++ b/lib/core/bookkeeping/period-service.ts @@ -363,6 +363,80 @@ export async function createPreviousPeriod( return newPeriod as FiscalPeriod } +export type PeriodStatusValue = 'open' | 'locked' | 'closed' + +export interface PeriodStatusForDate { + period_id: string | null + status: PeriodStatusValue + /** + * For `locked` status: either the period's `locked_at` timestamp (ISO) or the + * company-wide `bookkeeping_locked_through` date (ISO) — whichever applies. + * `null` for open/closed. + */ + lock_date: string | null +} + +/** + * Resolve the period status for a given affärshändelse date — answers + * "can a verifikation with this entry_date be posted right now?" using the + * same two-layer logic the DB triggers enforce: + * + * 1. company-wide bookkeeping_locked_through (covers everything on/before) + * 2. the fiscal_period covering the date (is_closed or locked_at) + * + * Returned shape is the canonical `period_status` envelope threaded into MCP + * tool responses so agents and widgets can disable writes without round-trips. + * + * Mirrors lib/api/v1/check-period-lock.ts (used by the v1 REST surface). The + * two helpers share the same query pattern; if either changes, update both. + */ +export async function resolvePeriodStatusForDate( + supabase: SupabaseClient, + companyId: string, + date: string, +): Promise { + // Layer 1: company-wide lock date. + const { data: settings } = await supabase + .from('company_settings') + .select('bookkeeping_locked_through') + .eq('company_id', companyId) + .maybeSingle() + const lockThrough = settings?.bookkeeping_locked_through ?? null + if (lockThrough && date <= lockThrough) { + // Find the covering period if any — useful for widget greying. + const { data: period } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .lte('period_start', date) + .gte('period_end', date) + .maybeSingle() + return { period_id: period?.id ?? null, status: 'locked', lock_date: lockThrough } + } + + // Layer 2: fiscal period status. + const { data: period } = await supabase + .from('fiscal_periods') + .select('id, is_closed, locked_at') + .eq('company_id', companyId) + .lte('period_start', date) + .gte('period_end', date) + .maybeSingle() + + if (!period) { + // No covering period — treated as open at this layer; the engine's own + // ensure-period helper will create one. Agents should still warn the user. + return { period_id: null, status: 'open', lock_date: null } + } + if (period.is_closed) { + return { period_id: period.id, status: 'closed', lock_date: null } + } + if (period.locked_at) { + return { period_id: period.id, status: 'locked', lock_date: period.locked_at } + } + return { period_id: period.id, status: 'open', lock_date: null } +} + /** * Get status summary for a fiscal period. */ diff --git a/lib/init.ts b/lib/init.ts index 6fc6eb11..6b955e30 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -18,11 +18,16 @@ const REQUIRED_CORE_VARS = [ 'CRON_SECRET', ] as const -const REQUIRED_EXTENSION_VARS = [ - 'ENABLE_BANKING_APP_ID', - 'ENABLE_BANKING_PRIVATE_KEY', - 'ANTHROPIC_API_KEY', - 'OPENAI_API_KEY', +// Each entry is one logical requirement; if multiple names are listed, the +// requirement is satisfied when ANY of them is set. Mirrors the runtime +// fallback in extensions/general/enable-banking/lib/jwt.ts (_PRODUCTION || +// base) so Vercel prod (which only sets the _PRODUCTION variants) doesn't +// warn on every cold start. +const REQUIRED_EXTENSION_VARS: ReadonlyArray = [ + ['ENABLE_BANKING_APP_ID_PRODUCTION', 'ENABLE_BANKING_APP_ID'], + ['ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_PRIVATE_KEY'], + ['ANTHROPIC_API_KEY'], + ['OPENAI_API_KEY'], ] as const function validateEnvironment(): void { @@ -43,8 +48,10 @@ function validateEnvironment(): void { } const missingExt: string[] = [] - for (const v of REQUIRED_EXTENSION_VARS) { - if (!process.env[v]) missingExt.push(v) + for (const aliases of REQUIRED_EXTENSION_VARS) { + if (!aliases.some((v) => !!process.env[v])) { + missingExt.push(aliases.join(' or ')) + } } if (missingExt.length > 0) { diff --git a/lib/pending-operations/__tests__/voucher-executors.test.ts b/lib/pending-operations/__tests__/voucher-executors.test.ts index 6f133059..8a22b96a 100644 --- a/lib/pending-operations/__tests__/voucher-executors.test.ts +++ b/lib/pending-operations/__tests__/voucher-executors.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/bookkeeping/engine', async () => { ...actual, createJournalEntry: vi.fn(), findFiscalPeriod: vi.fn(), + reverseEntry: vi.fn(), } }) @@ -30,7 +31,7 @@ vi.mock('@/lib/core/bookkeeping/storno-service', async () => { }) import { commitPendingOperation } from '../commit' -import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' +import { createJournalEntry, findFiscalPeriod, reverseEntry } from '@/lib/bookkeeping/engine' import { correctEntry } from '@/lib/core/bookkeeping/storno-service' function makePendingOp(overrides: Partial): PendingOperation { @@ -423,3 +424,237 @@ describe('commitPendingOperation: correct_entry', () => { expect(correctEntry).not.toHaveBeenCalled() }) }) + +// ─── reverse_entry ────────────────────────────────────────────────── + +describe('commitPendingOperation: reverse_entry', () => { + it('happy path: posts storno for a posted entry in an open period', async () => { + vi.mocked(reverseEntry).mockResolvedValueOnce( + makeJournalEntry({ id: 'je-storno', voucher_number: 99, voucher_series: 'A', fiscal_period_id: 'fp-1' }) + ) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'je-original', + status: 'posted', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: false }, + }, + error: null, + }) // executor's pre-flight fetch + enqueue({ data: null, error: null }) // dispatcher's commit update + + const op = makePendingOp({ + operation_type: 'reverse_entry', + params: { entry_id: 'je-original' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ + original_entry_id: 'je-original', + reversal_entry_id: 'je-storno', + reversal_voucher_number: 99, + reversal_voucher_series: 'A', + }) + expect(reverseEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'je-original', + undefined + ) + }) + + it('forwards reversal_date when provided', async () => { + vi.mocked(reverseEntry).mockResolvedValueOnce( + makeJournalEntry({ id: 'je-storno', voucher_number: 100, fiscal_period_id: 'fp-1' }) + ) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'je-original', + status: 'posted', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: false }, + }, + error: null, + }) + enqueue({ data: null, error: null }) + + const op = makePendingOp({ + operation_type: 'reverse_entry', + params: { entry_id: 'je-original', reversal_date: '2026-05-20' }, + }) + + await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(reverseEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'je-original', + '2026-05-20' + ) + }) + + it('returns 404 when the original entry does not exist', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) // pre-flight finds no row + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'reverse_entry', + params: { entry_id: 'je-missing' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.auto_rejected).toBe(true) + expect(result.http_status).toBe(404) + expect(reverseEntry).not.toHaveBeenCalled() + }) + + it('returns 409 when the original entry is not posted', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ + data: { + id: 'je-draft', + status: 'draft', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: false }, + }, + error: null, + }) + enqueue({ data: null, error: null }) + + const op = makePendingOp({ + operation_type: 'reverse_entry', + params: { entry_id: 'je-draft' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + expect(result.error).toMatch(/bokförda verifikationer kan makuleras/) + expect(reverseEntry).not.toHaveBeenCalled() + }) + + it('returns 409 when the period is closed', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ + data: { + id: 'je-original', + status: 'posted', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: true }, + }, + error: null, + }) + enqueue({ data: null, error: null }) + + const op = makePendingOp({ + operation_type: 'reverse_entry', + params: { entry_id: 'je-original' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + expect(result.error).toMatch(/omprövning/i) + expect(reverseEntry).not.toHaveBeenCalled() + }) + + it('returns 400 when entry_id is missing', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) + + const op = makePendingOp({ + operation_type: 'reverse_entry', + params: {}, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(reverseEntry).not.toHaveBeenCalled() + }) + + it('returns 500 with BFL invariant error if engine returns a storno in a different period', async () => { + // Engine guarantee per BFL 5 kap 5§: storno lands in original.fiscal_period_id + // (lib/bookkeeping/engine.ts:492). The executor asserts this so a future engine + // change that breaks the invariant fails fast. + vi.mocked(reverseEntry).mockResolvedValueOnce( + makeJournalEntry({ id: 'je-storno', voucher_number: 99, fiscal_period_id: 'fp-WRONG' }) + ) + + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ + data: { + id: 'je-original', + status: 'posted', + entry_date: '2026-05-15', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: false }, + }, + error: null, + }) + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'reverse_entry', + params: { entry_id: 'je-original' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(500) + expect(result.error).toMatch(/BFL invariant broken/i) + }) + + it('returns 409 when the entry_date is covered by the company-wide lock', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { + id: 'je-original', + status: 'posted', + entry_date: '2025-12-15', + fiscal_period_id: 'fp-1', + fiscal_periods: { is_closed: false }, + }, + error: null, + }) // pre-flight fetch — per-period OK + // resolvePeriodStatusForDate: company_settings says 2025-12-31 lock_through. + enqueue({ data: { bookkeeping_locked_through: '2025-12-31' }, error: null }) + enqueue({ data: { id: 'fp-1' }, error: null }) // covering period lookup + enqueue({ data: null, error: null }) // dispatcher's reject update + + const op = makePendingOp({ + operation_type: 'reverse_entry', + params: { entry_id: 'je-original' }, + }) + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + expect(result.error).toMatch(/låst|omprövning/i) + expect(reverseEntry).not.toHaveBeenCalled() + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index ec9c5a42..e35ad009 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -29,7 +29,7 @@ import { } from '@/lib/bookkeeping/invoice-entries' import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine' import { correctEntry } from '@/lib/core/bookkeeping/storno-service' -import { closePeriod, lockPeriod, unlockPeriod } from '@/lib/core/bookkeeping/period-service' +import { closePeriod, lockPeriod, unlockPeriod, resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' import { executeYearEndClosing, generateOpeningBalances, @@ -1715,9 +1715,14 @@ async function commitCorrectEntry( // Falling into correctEntry without this returns a less helpful DB error and // half-creates the storno before rolling back; surfacing the Swedish message // here matches the period_locked UX everywhere else in the app. + // + // Period lock check is two-layer (matches the DB triggers): per-period + // (is_closed / locked_at) AND company-wide (bookkeeping_locked_through). + // The staging tool uses resolvePeriodStatusForDate; we reuse it here so the + // commit-time gate matches the staging-time signal. const { data: original, error: origErr } = await supabase .from('journal_entries') - .select('id, status, fiscal_period_id, fiscal_periods!inner(is_closed)') + .select('id, status, entry_date, fiscal_period_id, fiscal_periods!inner(is_closed, locked_at)') .eq('id', entryId) .eq('company_id', companyId) .maybeSingle() @@ -1731,14 +1736,32 @@ async function commitCorrectEntry( status: 409, } } - const period = original.fiscal_periods as { is_closed?: boolean } | { is_closed?: boolean }[] | null - const periodClosed = Array.isArray(period) ? period[0]?.is_closed : period?.is_closed - if (periodClosed) { + const period = original.fiscal_periods as { is_closed?: boolean; locked_at?: string | null } | { is_closed?: boolean; locked_at?: string | null }[] | null + const periodRow = Array.isArray(period) ? period[0] : period + if (periodRow?.is_closed || periodRow?.locked_at) { return { error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.', status: 409, } } + // resolvePeriodStatusForDate also covers the company-wide bookkeeping_locked_through + // gate. A DB blip here would otherwise propagate as a 500 with a raw Postgres + // message; wrap so the caller sees a clean Swedish 500 instead, consistent with + // the staging-side log-and-degrade behaviour in stagePendingOperation. + try { + const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, original.entry_date) + if (periodStatus.status === 'locked' || periodStatus.status === 'closed') { + return { + error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.', + status: 409, + } + } + } catch (err) { + return { + error: `Kunde inte verifiera periodstatus: ${err instanceof Error ? err.message : 'okänt fel'}`, + status: 500, + } + } try { // correctEntry() posts both the storno and the corrected entry into the @@ -1763,6 +1786,89 @@ async function commitCorrectEntry( } } +async function commitReverseEntry( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const entryId = params.entry_id as string + const reversalDate = typeof params.reversal_date === 'string' ? params.reversal_date : undefined + + if (!entryId) { + return { error: 'entry_id is required', status: 400 } + } + + // Pre-flight matches commitCorrectEntry: posted + period not closed. Surfaces + // Swedish messages before reverseEntry() throws less helpful errors. Period + // lock check is two-layer (per-period + company-wide bookkeeping_locked_through) + // via resolvePeriodStatusForDate, matching the staging-time signal. + const { data: original, error: origErr } = await supabase + .from('journal_entries') + .select('id, status, entry_date, fiscal_period_id, fiscal_periods!inner(is_closed, locked_at)') + .eq('id', entryId) + .eq('company_id', companyId) + .maybeSingle() + + if (origErr || !original) { + return { error: 'Verifikationen hittades inte.', status: 404 } + } + if (original.status !== 'posted') { + return { + error: `Endast bokförda verifikationer kan makuleras. Aktuell status: ${original.status}.`, + status: 409, + } + } + const period = original.fiscal_periods as { is_closed?: boolean; locked_at?: string | null } | { is_closed?: boolean; locked_at?: string | null }[] | null + const periodRow = Array.isArray(period) ? period[0] : period + if (periodRow?.is_closed || periodRow?.locked_at) { + return { + error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.', + status: 409, + } + } + try { + const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, original.entry_date) + if (periodStatus.status === 'locked' || periodStatus.status === 'closed') { + return { + error: 'Räkenskapsperioden är låst. Öppna perioden eller använd omprövning för redan inlämnade momsdeklarationer.', + status: 409, + } + } + } catch (err) { + return { + error: `Kunde inte verifiera periodstatus: ${err instanceof Error ? err.message : 'okänt fel'}`, + status: 500, + } + } + + try { + const reversal = await reverseEntry(supabase, companyId, userId, entryId, reversalDate) + // Invariant per BFL 5 kap 5§: the storno must land in the same fiscal period + // as the original entry. reverseEntry() at lib/bookkeeping/engine.ts:492 uses + // original.fiscal_period_id, but assert it here so a future engine change that + // breaks this invariant fails fast instead of silently shifting period attribution. + if (reversal.fiscal_period_id !== original.fiscal_period_id) { + return { + error: `BFL invariant broken: storno period ${reversal.fiscal_period_id} differs from original ${original.fiscal_period_id}.`, + status: 500, + } + } + return { + data: { + original_entry_id: entryId, + reversal_entry_id: reversal.id, + reversal_voucher_number: reversal.voucher_number, + reversal_voucher_series: reversal.voucher_series, + fiscal_period_id: reversal.fiscal_period_id, + }, + } + } catch (err) { + if (isBookkeepingError(err)) throw err + return { error: err instanceof Error ? err.message : 'Failed to reverse entry', status: 500 } + } +} + // ── Public dispatcher ──────────────────────────────────────────── /** @@ -1880,6 +1986,9 @@ export async function commitPendingOperation( case 'correct_entry': result = await commitCorrectEntry(supabase, userId, companyId, pendingOp.params) break + case 'reverse_entry': + result = await commitReverseEntry(supabase, userId, companyId, pendingOp.params) + break default: return { status: 'failed', diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index d12c64eb..c212bbf8 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -64,6 +64,7 @@ export const OPERATION_RISK_TIERS: Record = { // The arbitrary-line capability is what makes these compliance-critical. create_voucher: 'high', correct_entry: 'high', + reverse_entry: 'high', } export function getRiskLevel(operationType: string): RiskLevel { diff --git a/supabase/migrations/20260516060000_journal_entries_source_type_inbox_item.sql b/supabase/migrations/20260516060000_journal_entries_source_type_inbox_item.sql new file mode 100644 index 00000000..aa105bf5 --- /dev/null +++ b/supabase/migrations/20260516060000_journal_entries_source_type_inbox_item.sql @@ -0,0 +1,31 @@ +-- Migration: add 'inbox_item' to journal_entries.source_type CHECK constraint +-- +-- The `/api/extensions/ext/invoice-inbox/items/:id/book-direct` route uses +-- source_type='inbox_item' when booking a standalone verifikation from the +-- document inbox (no bank-transaction link). The TS type +-- (JournalEntrySourceType in types/index.ts) and the Zod schema +-- (JournalEntrySourceTypeSchema in lib/api/schemas.ts) already list it, but +-- the DB CHECK constraint was never updated -- so every "Bokför direkt" +-- without a linked transaction failed with PG 23514, surfaced as the generic +-- "Verifikationen kunde inte sparas. Försök igen." error. +-- +-- See 20260513170001 for the previous expansion pattern. + +ALTER TABLE public.journal_entries + DROP CONSTRAINT IF EXISTS journal_entries_source_type_check; + +ALTER TABLE public.journal_entries + ADD CONSTRAINT journal_entries_source_type_check + CHECK (source_type IN ( + 'manual', 'bank_transaction', 'invoice_created', + 'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment', + 'opening_balance', 'year_end', + 'storno', 'correction', 'import', 'system', + 'inbox_item', + 'supplier_invoice_registered', 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', 'supplier_credit_note', + 'currency_revaluation', + 'supplier_invoice_privately_paid' + )); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 36aa5f17..a9591339 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1361,6 +1361,8 @@ export type PendingOperationType = // Phase 4: arbitrary-line bookkeeping primitives | 'create_voucher' | 'correct_entry' + // Pure makulering (storno) of a posted entry — agent-native API plan item 38 + | 'reverse_entry' export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected' export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'