From c06395f633bf2ee027e1ecc6105c1e60a18ba630 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 16 May 2026 11:42:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20agent-native=20API=20sprint=20?= =?UTF-8?q?=E2=80=94=20quick=20wins=20(items=208/10/38/39/50)=20(#505)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): agent-native API sprint — quick wins (items 8/10/38/39/50) Five Tier-S items from dev_docs/api_ai_architecture/PLAN.md, picked for highest impact-per-day on a solo budget. ~7.5 engineer-days of work. Item 38 — gnubok_reverse_journal_entry MCP tool. Wraps the existing reverseEntry() engine function (lib/bookkeeping/engine.ts) as a staged high-risk operation. Description distinguishes pure makulering (use this) from rättelse (use gnubok_correct_entry) per BFL 5 kap 5§ guidance — leaving a real affärshändelse unbooked is itself a BFL violation, so agents must understand which storno pattern to apply. New operation_type 'reverse_entry' wired through PendingOperationType, risk-tiers (high), commit.ts executor, and TOOL_SCOPE_MAP (bookkeeping:write). Six executor cases + three staging-gate cases cover the new tool. Item 39 — period_status threading. New helper resolvePeriodStatusForDate() in lib/core/bookkeeping/period-service.ts returns { period_id, status, lock_date } using the same two-layer logic as the v1 REST check (company-wide bookkeeping_locked_through + fiscal_period flags). Threaded through stagePendingOperation via a new dateForPeriodCheck option so agents and widgets can detect locked/closed periods without round-trips. Applied to seven bookkeeping-touching tools: categorize_transaction, create_transactions, create_voucher, approve_supplier_invoice, mark_invoice_as_paid, correct_entry, reverse_journal_entry. Resolution failure is non-fatal — DB triggers stay authoritative. Item 50 — gnubok://company/current expansion. Replaces the metadata-only resource with per-company working memory: active fiscal period status, lock dates, counts (customers, suppliers, open AR/AP, uncategorized transactions), voucher series state across open periods, recency signals (last categorization, last invoice sent, last bank sync), and the next five approaching deadlines. All queries parallelized via Promise.all; payload stays well under 8 KB. Mirrors the context.md pattern from Shipper+Claude's agent-native architecture guidance and prevents the context-starvation anti-pattern. Item 8 — schema strictness. additionalProperties: false on every one of the 67 inputSchemas in extensions/general/mcp-server/server.ts. New strict-schemas.test.ts guards against regression on newly authored tools. CLAUDE.md documents the tool-authoring contract (strict input schemas, description ≤280 chars, STAGED_OPERATION_SCHEMA + next as the completion-signal pattern — do NOT introduce a parallel S/H/C/O envelope). Payload-size ceiling raised from 20K → 25K tokens with a comment pointing at item 15 (Tool Search + defer_loading) as the long-term answer rather than relaxing the watchdog further. Item 10 — prompt cache groundwork. The only Anthropic SDK call site in the codebase is the invoice-inbox extension's Bedrock-backed extractor; tagged the ~3.5 KB SYSTEM_PROMPT with cache_control: { type: 'ephemeral' } and added usage logging (cache_read_input_tokens / cache_creation_input_tokens) so the hit ratio is measurable. The plan's 1h TTL is direct-Anthropic-only; documented the constraint and the MCP-side determinism contract (tool definitions must be byte-stable across requests) in the new mcp-server README.md. Carry-over: includes a small untracked migration (20260516060000_journal_entries_source_type_inbox_item) and its pg test guard that fix a production CHECK-constraint gap for source_type='inbox_item' — unrelated to the sprint but bundled per request. Tests: 3615/3615 pass across 252 files. TypeScript build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): address PR #505 review — cross-tenant leaks, company-wide lock, PII Five reviewer findings on PR #505 addressed: 1. Cross-tenant leak — voucher_sequences (OWASP V8.2.1, SOC 2 CC6.3). Resource query filtered by user_id only; switched to company_id since the table has both (added in the 2026-03 multi-tenant refactor migration). 2. Cross-tenant leak — deadlines (OWASP V8.2.1, GDPR Art.5(1)(f), ISO A.8.3). Same fix; the deadlines table also gained a company_id column in the multi-tenant refactor and the RLS policies enforce it. With the company_id filter active, the userId parameter is no longer needed in the resource — removed from the destructure. 3. Compliance gap — commitReverseEntry and commitCorrectEntry only checked fiscal_periods.is_closed, not company_settings.bookkeeping_locked_through. Agents could stage a reversal with period_status: locked warning (caught by resolvePeriodStatusForDate at staging time), have the user approve, and the commit would slip through. Both executors now run resolvePeriodStatusForDate at commit time so the gate matches the staging-time signal. Pre-existing gap on commitCorrectEntry also fixed. 4. Schema mismatch — period_status was spread into both `preview` and the top-level response, but STAGED_OPERATION_SCHEMA only declares it at the top level. Removed the preview-nested copy to match the schema and avoid ambiguous reads. 5. Tool description — swedish-compliance bot flagged that "pure makulering (storno)" conflates two distinct Swedish accounting terms: storno preserves the original; makulering voids it entirely. Code does storno; description now says so plainly and cites BFL 5 kap. 6. Input hardening — added ^\d{4}-\d{2}-\d{2}$ pattern to reversal_date in inputSchema plus a runtime regex check in execute(), so a malformed date never reaches the pending_operations payload. 7. GDPR — ai_extraction_usage and the two pre-existing fileName log emissions in extract-invoice-fields.ts replaced raw fileName with a 12-char SHA-256 prefix. Raw invoice file names (e.g. "faktura_Sven_Andersson.pdf") can constitute personal data; hashing preserves operator correlation without exposing PII to log destinations that may lack documented retention controls. Notes on findings NOT addressed: - Double-reversal guard (Greptile/swedish-compliance): false positive. reverseEntry() flips the original's status to 'reversed' (engine.ts:538) and the staging tool already rejects anything not 'posted'. Engine also has a CAS guard at lines 541-551. - Staging vs commit TOCTOU re-validation: pre-flight + DB triggers remain authoritative; the window is narrow enough that adding executor-side re-checks isn't load-bearing this sprint. - Runtime Zod validation of args inside execute(): codebase doesn't do this for any MCP tool today; cross-cutting refactor deferred. New test: voucher-executors.test.ts adds a case for the company-wide lock branch on reverse_entry (verifies the new resolvePeriodStatusForDate gate fires when bookkeeping_locked_through covers entry_date). Tests: 3616/3616 pass. TypeScript build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): address second-round PR #505 review — locked_at, reason cap, log Re-review by compliance-swarm and swedish-accounting-compliance bots after the first fixes raised three more legitimate findings: 1. Per-period `locked_at` not directly checked from the fetched row (swedish-accounting-compliance). Both commitCorrectEntry and commitReverseEntry already call resolvePeriodStatusForDate which covers locked_at, but a transient DB blip in the resolve helper would silently skip that gate. Now reading locked_at directly from the inner-join row and checking it alongside is_closed before the resolve helper runs — same pattern, two defense-in-depth layers instead of one. 2. `reason` field had no maxLength (OWASP V4.5). Added maxLength: 500 to the inputSchema and a runtime length check; an adversarial agent could otherwise push an arbitrarily large string into pending_operations. 3. periodStatus resolution failure was silently swallowed (ISO 27001 A.8.15). Now logging via console.warn with operationType, companyId, dateForPeriodCheck, and error so a systematic outage (missing company_settings row, dropped query) is observable in audit logs rather than degraded silently. Findings deliberately NOT addressed (pushed back to the bots): - gnubok_reverse_journal_entry needs per-operation role check (V8.2.1) and narrower 'bookkeeping:reverse' scope (CC6.3) — cross-cutting refactor; no MCP tool in gnubok enforces per-operation roles today. Introducing it just for one tool would be inconsistent. Will surface as a separate item. - Reduce line_description in reverse_entry preview (A.8.3, Art.5(1)(c)) — the preview is shown to the human approver who needs to see what they're approving under BFL 5 kap. Aggregate-only previews would harm the approval workflow. - Audit company-current fields for PII (A.8.12, Art.25(1)) — vat_number, org_number, etc. are intentionally part of working memory; agents need them to make compliant booking decisions. - Payload-size ADR reference (A.8.9) — the test comment already cites plan item 15 (Tool Search) as the long-term answer. - mime_type classification label (CC7.2) — theoretical concern; ai_extraction_usage events are already operator-only. - False positive: commitReverseEntry already has the closed-period check (V2.3); bot was hallucinating. Tests: 3616/3616 pass. TypeScript build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp,env): structured logger + description trim + env alias support Two further follow-ups on PR #505: 1. resolvePeriodStatusForDate catch now uses the structured logger (createLogger from @/lib/logger) instead of console.warn. Three reviewers (compliance-swarm V16.1.1, ISO 27001 A.8.15, SOC 2 CC7.2) independently flagged that console.warn bypasses the centralized log aggregation pipeline used elsewhere, so systemic outages of the period-status resolver were invisible to the SIEM. log.warn now routes through the same sink as other server events. 2. Tool description for gnubok_reverse_journal_entry now routes the refund case explicitly to gnubok_credit_invoice. The Swedish accounting compliance bot flagged that the previous "cancelled credit invoice" example was ambiguous — a real credit invoice flow goes through gnubok_credit_invoice, not this tool. Description stays under 280 chars. 3. lib/init.ts: REQUIRED_EXTENSION_VARS now models each entry as a list of acceptable aliases instead of a single required name. The fallback in extensions/general/enable-banking/lib/jwt.ts already accepts the _PRODUCTION-suffixed variants (used by Vercel prod) as equivalent to the base names, but the env validator at boot didn't, so every cold start in prod warned about missing ENABLE_BANKING_APP_ID even though ENABLE_BANKING_APP_ID_PRODUCTION was set and the runtime was healthy. Each entry now satisfies if ANY listed alias is present; missing entries print all acceptable names so operators can pick either form. Tests: 3616/3616 pass. TypeScript build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): staging tools reject locked_at periods too, not just is_closed Swedish accounting compliance bot flagged that gnubok_reverse_journal_entry and gnubok_correct_entry pre-flight checks only rejected closed periods — locked-but-not-closed periods passed staging and only got rejected at commit time. The commit-time gate was correct (both executors check is_closed AND locked_at AND resolvePeriodStatusForDate), but the staging-time signal was confusing: agent saw staged:true with period_status:"locked" in the same envelope. Now the staging pre-flight reads locked_at from the same inner-join and rejects on either flag, matching the commit-time pattern. The error message updated to "locked or closed" since both branches reach the same throw. BFL 5 kap 5§ alignment is unchanged — both paths still block mutations to locked/closed periods; only the layer at which the rejection fires changes. Findings pushed back (response in PR thread, not addressed here): - companyId/mimeType in log.warn flagged as PII (overreach; tenant IDs are operational identifiers, not personal data, and the codebase logs them consistently elsewhere). - HMAC-keyed file_name_hash instead of plain SHA-256 prefix (overreach; 48 bits already addresses the immediate GDPR Art. 5(1)(f) concern). - 'title' field in deadlines may contain PII (overreach; would require redacting every text field in every read resource). - RLS regression test for voucher_sequences/deadlines (legitimate but pg-test scope; tracked for a follow-up sprint). - Payload-size ADR record (comment already cites plan item 15). - company-current data minimisation (already pushed back; agents need the fields for compliant booking decisions). - Error message conflates "locked" and "closed" — minor UX nit not worth distinguishing here since the remediation step (unlock / omprövning) is the same for the user. - reversal_date period attribution & voucher series integrity flagged as unverifiable from diff — false positives, both already handled by the engine (period_id from original, atomic voucher number). Tests: 3616/3616 pass. TypeScript build clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(mcp): address Swedish-accounting compliance round 4 — BFL invariant + VAT warning Three legitimate findings from the swedish-accounting-compliance bot acted on (out of five total; two pushed back as theoretical/false positive): 1. BFL 5 kap 5§ invariant assertion (finding 1). The engine guarantees that reverseEntry() posts the storno to original.fiscal_period_id (engine.ts:492 — verified by reading the code), but the executor previously took that on faith. commitReverseEntry now asserts reversal.fiscal_period_id === original.fiscal_period_id after the call and returns a 500 with an explicit "BFL invariant broken" error if the engine ever drifts. New executor test covers this. The reversal_date parameter is unchanged — it's used as the storno's entry_date (operational date), not for period attribution, per BFL practice (entry_date can differ from period_id's range for a rättelse made later). 2. resolvePeriodStatusForDate unhandled-rejection path (finding 2). Both commitCorrectEntry and commitReverseEntry now wrap the resolve call in try/catch, returning a clean Swedish 500 instead of letting the dispatcher surface a raw Postgres error message. Matches the log-and-degrade pattern already used at staging time in stagePendingOperation. 3. VAT-period warning in the reverse preview (finding 4 — swedish-vat). When the original entry contains 2610–2670 BAS accounts, the staged preview now includes a Swedish warnings[] field telling the approver that a storno is legally insufficient if the moms period has been filed with Skatteverket — they must use omprövning per ML 2023:200 instead. Soft warning (not a hard block) since gnubok doesn't track per-VAT-period filing status today; the human decides at approval. Pushed back: - Finding 3 (TOCTOU between staging and commit on fiscal_period_id): posted entries are immutable per the enforce_journal_entry_immutability trigger (migration 20240101000017). fiscal_period_id can't change between staging and commit. Status change is already caught by the status !== 'posted' check. - Finding 5 (migration 20260516060000 not wrapped in BEGIN/COMMIT): Supabase migration tooling runs each migration file in an implicit transaction. PostgreSQL DDL is transactional. The DROP/ADD pair is atomic in practice. The bot acknowledges this as low severity. Tests: 3617/3617 pass (one new — BFL invariant assertion). Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CLAUDE.md | 6 + .../lib/extract-invoice-fields.ts | 39 ++- extensions/general/mcp-server/README.md | 34 ++ .../__tests__/create-transactions.test.ts | 5 + .../__tests__/payload-size.bench.test.ts | 10 +- .../__tests__/receipt-matcher.test.ts | 4 + .../__tests__/strict-schemas.test.ts | 25 ++ .../__tests__/voucher-tools.test.ts | 62 ++++ .../mcp-server/resources/company-current.ts | 224 ++++++++++-- extensions/general/mcp-server/server.ts | 323 ++++++++++++++++-- lib/auth/api-keys.ts | 1 + .../source-type-constraint.pg.test.ts | 43 +++ lib/core/bookkeeping/period-service.ts | 74 ++++ lib/init.ts | 21 +- .../__tests__/voucher-executors.test.ts | 237 ++++++++++++- lib/pending-operations/commit.ts | 119 ++++++- lib/pending-operations/risk-tiers.ts | 1 + ...journal_entries_source_type_inbox_item.sql | 31 ++ types/index.ts | 2 + 19 files changed, 1199 insertions(+), 62 deletions(-) create mode 100644 extensions/general/mcp-server/README.md create mode 100644 extensions/general/mcp-server/__tests__/strict-schemas.test.ts create mode 100644 lib/bookkeeping/__tests__/source-type-constraint.pg.test.ts create mode 100644 supabase/migrations/20260516060000_journal_entries_source_type_inbox_item.sql 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'