feat(mcp): agent-native API sprint — quick wins (items 8/10/38/39/50) (#505)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-16 11:42:47 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent c3021f1ec6
commit c06395f633
19 changed files with 1199 additions and 62 deletions
+6
View File
@@ -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
@@ -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,
+34
View File
@@ -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
@@ -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(
@@ -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)
})
})
@@ -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
])
@@ -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<string, unknown> | undefined
return !schema || schema.additionalProperties !== false
})
.map((t) => t.name)
expect(missing).toEqual([])
})
})
@@ -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)
})
})
@@ -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 ?? [],
}
},
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -132,6 +132,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
// 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 {
@@ -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)
})
})
+74
View File
@@ -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<PeriodStatusForDate> {
// 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.
*/
+14 -7
View File
@@ -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<readonly string[]> = [
['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) {
@@ -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>): 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()
})
})
+114 -5
View File
@@ -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<string, unknown>
): Promise<ExecutorResult> {
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',
+1
View File
@@ -64,6 +64,7 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// 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 {
@@ -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';
+2
View File
@@ -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'