feat(mcp): agent-native improvements — progressive discovery, widgets, skills, telemetry (#393)
* feat(mcp): agent-native improvements — progressive discovery, widgets, skills, telemetry
Four coordinated streams of MCP server improvements that move gnubok toward
agent-first design, grounded in Anthropic's Nov 2025 "Code execution with MCP"
article and the May 2026 MCP conference talk.
Context budget — minimize tools/list payload
- New gnubok_search_tools: progressive discovery with name|summary|full detail
levels and scope filtering. Agents pull only the schemas they need.
- Trimmed all 50 tool descriptions from multi-paragraph blocks (avg ~500-1000
chars) to one-sentence summaries (avg ~120 chars). Args/Returns/Examples
blocks dropped — they duplicated inputSchema.
- outputSchema declared on every tool; structuredContent emitted on every
successful tools/call (was previously only widget-tagged tools).
- protocolVersion bumped to 2025-06-18 (negotiates back to 2024-11-05).
- Workflow examples consolidated into initialize.instructions.
- Net effect: tools/list payload ~43 KB / ~10.8K tokens for 51+ tools, with
headroom guard at 20K tokens.
MCP applications — server-shipped UI widgets
- New widgets/ directory with typed UiWidget contract; receipt-matcher moved
out of widget-html.ts (which was deleted) into widgets/receipt-matcher.ts.
- New gnubok_vat_review_widget tool + interactive momsdeklaration widget
(all 8 rutor with summary card, theme-aware light/dark, copy buttons).
- resources/list and resources/read iterate uiWidgets dynamically — adding
the next widget is a single file drop.
Skills over MCP — domain-knowledge primitive
- 5 user-facing SKILL.md-style workflow guides authored from existing
.claude/skills/swedish-* development skills:
• month-end-close — book → reconcile → VAT (monthly filers) → lock
• quarterly-vat-review — ruta-by-ruta map, deadlines, common errors
• year-end-close — bokslut, bokslutstransaktioner, lock → year-end
→ opening balances → close (irreversible)
• invoicing-rules — ML 17 kap. 24 §, customer types, ROT/RUT, Peppol
• payroll-monthly — salary run → calculate → review → AGI XML
- gnubok_list_skills (with optional tag filter) + gnubok_load_skill(slug).
Both unscoped — available to any authenticated key.
- Each skill also exposed as MCP resource at gnubok://skill/<slug>
(text/markdown) for forward compatibility with a future native
skills/list primitive.
Tool-call telemetry — measure before optimizing further
- Three new CoreEvent types (mcp.tool_called, mcp.tools_list_called,
mcp.resource_read), all persisted to event_log (30-day TTL, RLS-scoped).
- Fire-and-forget emission from the dispatcher — never blocks JSON-RPC
response, double-guarded against handler failures.
- tools/call instrumented at all four exit points (success, execution
error, scope denied, unknown tool). Latency measured tightly around
tool.execute() — excludes dispatcher overhead.
- tools/list logs returned tool count (informs progressive-discovery
adoption); resources/read logs URI + kind discriminator (widget /
skill / data / unknown).
- No PII or secret material in payloads — only metadata.
Out of scope (explicitly deferred):
- Code-mode SDK (no production code-mode hosts to consume it yet).
- Elicitations (require streamable HTTP transport — bigger architectural lift).
- DB lockdown / RPC funnel (foundational; should follow once telemetry tells
us where writes actually flow).
- CRUD → intent endpoints (frontend coupling — multi-PR effort).
Tests: +37 new unit tests across search-tools, output-schema, payload-size,
vat-review-widget, skills, telemetry. Existing receipt-matcher test updated
for the new structuredContent contract. 2,615 unit tests passing.
Production build green. No new lint warnings or errors in changed files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mcp): address PR #393 review findings
Greptile (P1 + P2) and the Swedish accounting compliance bot flagged 9 issues
across security, data correctness, and skill content. All addressed:
Security (P1)
- gnubok_search_tools: scope filter now fails closed when __keyScopes is
absent. The earlier permissive default leaked the full tool inventory if
the dispatcher's hard-coded name check ever silently broke. Marker presence
is part of the contract — explicitly empty array also hides scoped tools.
Two new test cases pin the fail-closed behaviour.
Compliance — VAT (data correctness)
- get_vat_report now aggregates 2614/2624/2634 (reverse-charge output VAT)
and exposes them as ruta30/ruta31/ruta32 per SKV 4700. ruta48 also picks
up 2647 (missing before). ruta49 formula corrected to
(10+11+12+30+31+32) − 48. The widget renders the new rutor between the
Utgående and Ingående sections.
- Widget ruta 05 sub-label updated from "3001+3002+3003" to "all momspliktig
försäljning oavsett skattesats" — ruta 05 covers all domestic taxable
supplies, not just direct-rate sales.
- Refactored: extracted computeVatReport() helper used by both
gnubok_get_vat_report and gnubok_vat_review_widget. Removes the
rename-fragile tools.find() lookup at runtime.
Compliance — payroll
- payroll-monthly skill: replaced "born 1958 or earlier = 10.21%" (the 2024
formulation) with the statutory rule "age 66+ on 1 January of the income
year (67+ from income year 2026)". Removed the unsourced "age 16–18:
11.78%" row in favour of a current växa-stöd description with explicit
Prop. 2025/26:34 reference and a "verify against current Skatteverket
tables" caveat.
Compliance — skills text
- invoicing-rules: added explicit footnote on the 1 April 2026 livsmedel
rate change. Restaurang/servering stays at 12 %; livsmedel sold in other
forms drops to 6 %. Per Prop. 2025/26:55.
- year-end-close: clarified periodiseringsfond cap as "25 % of överskott
before this year's avsättning" (IL 30 kap.), removing the ambiguous
"skattemässigt resultat" phrasing that could be misread as a circular
after-fond computation.
Schema correctness
- STAGED_OPERATION_SCHEMA.required gains "staged" — every path through
stagePendingOperation returns the field, so the schema now matches the
contract that MCP clients validate against.
Tests: 2,617 passing (+2 for the search-tools fail-closed cases).
Production build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mcp): address PR #393 round-2 compliance review
Round 2 of the swedish-compliance bot ran after the previous fix-push and
flagged four substantive items + a recommendation. All addressed:
VAT computation (data correctness)
- ruta05 expanded beyond 3001/3002/3003 to cover the common BAS taxable-
revenue accounts (3001-3008, 3041-3048, 3051-3058, 3071-3078). Companies
that book to 30xx alternates were previously under-reporting taxable
turnover; now all standard BAS taxable-revenue numbering contributes.
- One-sided reverse-charge warning: when output VAT is booked on
2614/2624/2634 (rutor 30/31/32 > 0) but the matching calculated input
VAT (2645) is zero, computeVatReport now returns a Swedish-language
warning string. ruta49 is inflated in this case — the warning surfaces
the most common reverse-charge error per the swedish-vat skill. The
widget renders warnings in a terracotta panel above the summary card.
- computeVatReport exported and a focused unit test added — exercises 2647
inclusion in ruta48, reverse-charge balanced/unbalanced cases, and the
expanded ruta05 mapping. Fills the gap that prior tools/call integration
tests couldn't reach.
Skill content
- payroll-monthly Step 6: BAS journal-entry example no longer hard-codes
31.42 % on the 7510/2730 lines. The avgift line is now described as
"avgift_base × applicable_rate per employee" with explicit aggregation
semantics for runs that mix full-rate and reduced-rate employees.
Aligns with the Step-4 reduced-rate caveats already in place.
- invoicing-rules ROT/RUT block: replaced the bare "30 % / max 50 000 SEK"
text with the full year-by-year picture — RUT 50 % / 75 000 max, ROT
baseline 30 % / 50 000 max, 2024 H2 doubled ceiling, 2025 May–Dec
enhanced 50 % rate. Defaults to "verify against current Skatteverket
table" rather than a single hard-coded rate.
False positives in the round-2 review (no fix needed; documented for the
record):
- "Old vat_report path still computes ruta48 without 2647" — the old path
was replaced by computeVatReport in the previous push; the bot was
reading the diff hunk and conflating it with current behaviour.
- "Widget prose says ruta49 = (10+11+12) - 48" — no such prose exists in
widgets/vat-review.ts. The skill body has the correct
(10+11+12+30+31+32)-48 formula.
- "Including 'reversed' status entries in VAT aggregation may over-count
cross-period storno" — current behaviour is correct per Skatteverket
period-aligned filing: the reversed original stays in its own period,
the matching storno (status 'posted') lands in the reversal period,
and they net to zero across the full year. Adding code comment to
document.
Tests: 2,623 passing (+6 for computeVatReport unit tests). Production
build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mcp): address PR #393 round-3 compliance review
Compliance bot re-ran after the round-2 push and flagged three items:
VAT computation
- One-sided reverse-charge warning previously only checked 2645 (EU
acquisitions). For domestic reverse charge per ML 16:13 (byggtjänster,
electronics > 100k SEK, etc.) the matching input lands on 2647 — a
correctly-balanced 2614+2647 booking would have falsely fired the
warning. Fixed: warning now triggers only when *both* 2645 and 2647
are zero. Updated message text mentions both accounts. Added a test
case asserting the no-warning path for 2647-only-input.
Skill documentation drift
- quarterly-vat-review skill body still showed ruta05 source as
"3001 + 3002 + 3003" while the runtime computeVatReport sums 32 BAS
taxable-revenue accounts. Updated the skill table to read
"3001–3008, 3041–3048, 3051–3058, 3071–3078" so the auditor-facing
docs match the implementation.
outputSchema upgrade
- gnubok_get_vat_report and gnubok_vat_review_widget previously declared
outputSchema as the bare { type: 'object' }. Replaced with a shared
VAT_REPORT_OUTPUT_SCHEMA constant declaring period, period_label, all
11 rutor (with descriptions referencing source accounts), summary,
and warnings. Modern MCP clients that validate structuredContent
against outputSchema now have an accurate contract. Added a test
asserting the schema is non-trivial and declares every ruta the
runtime returns.
Tests: 2,625 passing (+2 for the 2647 warning path and the
outputSchema shape assertion). Production build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(mcp): address PR #393 round-4 compliance review
The compliance bot re-ran after the round-3 push with a fresh batch.
Real findings fixed; false positives documented.
VAT computation
- Removed 3004 (Försäljning inom Sverige, momsfri / VAT-exempt) from
RUTA_05_ACCOUNTS — round-2's expansion accidentally included it. Ruta 05
is the *taxable* base; exempt sales must NOT contribute. New test pins
the exclusion.
- Added 3106 (taxable EU goods supply, momspliktig) to RUTA_05_ACCOUNTS.
Used when EU buyer's VAT number is invalid or buyer is private.
- Added ruta35 — EU intra-community goods supplies, momsfri (account
3108). Previously omitted entirely from the rutor schema; SKV 4700
has it as a distinct box separate from ruta 39 (services) and ruta 40
(export outside EU). VatReportResult, VAT_REPORT_OUTPUT_SCHEMA, the
widget table, the copy-summary block, and the quarterly-vat-review
skill table all updated. New test covers 3108 → ruta35 mapping.
- Strengthened the comment on the posted+reversed status filter to
document why current behavior is correct per ML 2023:200 and
faktureringsmetoden (the bot's cross-period storno concern is a false
positive — see commit message rationale below).
Skill content
- invoicing-rules: added explicit BFL 5 kap. 6–7 § / ML 17 kap. 22–23 §
note that the kreditfaktura itself consumes a sequential number from
the same (or dedicated KR-) fakturaserie. The KR- prefix is a display
convention; the underlying löpnummer must be unbroken just like the
regular series.
- payroll-monthly: added the missing "born 1937 or earlier → 0 %"
cohort to the rate breakdown. Previously could lead a payroll run to
over-pay avgifter on the oldest cohort.
False positives in the round-4 review (verified, not changed)
- 2644/2648 as reverse-charge inputs: verified against gnubok's actual
BAS chart (lib/bookkeeping/bas-data/class-2-equity-liabilities.ts).
2644 does not exist; 2648 is "Vilande ingående moms" (dormant input
VAT for cash method), not RC at 6 %. Canonical RC inputs are 2645 (EU)
and 2647 (domestic) — both already covered.
- Cross-period storno over-count: per ML 2023:200 + Skatteverket
faktureringsmetoden, the original sale's VAT belongs to the invoice-
date period; the kreditfaktura's reduction belongs to the storno-date
period. Including 'reversed' status entries (which still have their
original date) is therefore correct. *Excluding* them would
under-report the original period and over-credit the reversal period.
Added a multi-line comment in computeVatReport documenting this.
Tests: 2,627 passing (+2 for ruta35 mapping and 3004 exclusion).
Production build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
432a8b60dc
commit
5c52f24a49
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
|
||||
describe('outputSchema coverage', () => {
|
||||
it('every tool declares an outputSchema', () => {
|
||||
const missing = tools.filter((t) => !t.outputSchema).map((t) => t.name)
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('every outputSchema is an object schema', () => {
|
||||
for (const t of tools) {
|
||||
expect(t.outputSchema, `tool ${t.name} outputSchema`).toBeDefined()
|
||||
const schema = t.outputSchema as Record<string, unknown>
|
||||
expect(schema.type, `tool ${t.name} outputSchema.type`).toBe('object')
|
||||
}
|
||||
})
|
||||
|
||||
it('every tool has a tight description (<= 280 chars)', () => {
|
||||
const tooLong = tools.filter((t) => t.description.length > 280)
|
||||
expect(tooLong.map((t) => `${t.name}: ${t.description.length} chars`)).toEqual([])
|
||||
})
|
||||
|
||||
it('no description embeds Args:/Returns:/Examples: blocks (those belong to JSON Schema)', () => {
|
||||
const verbose = tools.filter((t) =>
|
||||
/Args:\s*\n|Returns JSON:|Examples:\s*\n|Errors:\s*\n/.test(t.description)
|
||||
)
|
||||
expect(verbose.map((t) => t.name)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
|
||||
describe('tools/list payload size guard', () => {
|
||||
it('keeps the projected tools/list payload under the context-budget ceiling', () => {
|
||||
const projection = tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
...(t.outputSchema ? { outputSchema: t.outputSchema } : {}),
|
||||
annotations: t.annotations,
|
||||
...(t._meta ? { _meta: t._meta } : {}),
|
||||
}))
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -341,7 +341,7 @@ describe('MCP Receipt Matcher', () => {
|
||||
expect(result.content).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not include structuredContent for regular tools', async () => {
|
||||
it('also includes structuredContent for regular tools (alongside the text content block)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
|
||||
enqueueMany([
|
||||
{ data: tx, error: null },
|
||||
@@ -358,7 +358,9 @@ describe('MCP Receipt Matcher', () => {
|
||||
)
|
||||
const result = await parseResult(res)
|
||||
|
||||
expect(result.structuredContent).toBeUndefined()
|
||||
// Modern clients consume structuredContent directly when an outputSchema is declared.
|
||||
expect(result.structuredContent).toBeDefined()
|
||||
expect(result.structuredContent).toMatchObject({ staged: true })
|
||||
expect(result.content).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { ALL_SCOPES } from '@/lib/auth/api-keys'
|
||||
|
||||
const searchTool = tools.find((t) => t.name === 'gnubok_search_tools')!
|
||||
|
||||
async function call(args: Record<string, unknown>, keyScopes: string[] = ALL_SCOPES as unknown as string[]) {
|
||||
// Mirror the dispatcher: inject __keyScopes the way handleMcpRequest does.
|
||||
const argsWithScopes = { ...args, __keyScopes: keyScopes }
|
||||
return (await searchTool.execute(
|
||||
argsWithScopes,
|
||||
'company-id',
|
||||
'user-id',
|
||||
{} as never,
|
||||
{ type: 'api_key' }
|
||||
)) as {
|
||||
tools: Array<{ name: string; description?: string; scope: string | null; inputSchema?: unknown; outputSchema?: unknown }>
|
||||
count: number
|
||||
total_matched: number
|
||||
detail: 'name' | 'summary' | 'full'
|
||||
}
|
||||
}
|
||||
|
||||
describe('gnubok_search_tools', () => {
|
||||
it('is registered as a tool', () => {
|
||||
expect(searchTool).toBeDefined()
|
||||
expect(searchTool.annotations.readOnlyHint).toBe(true)
|
||||
})
|
||||
|
||||
it('returns all tools when query is empty (default summary detail)', async () => {
|
||||
const result = await call({})
|
||||
expect(result.detail).toBe('summary')
|
||||
expect(result.tools.length).toBeGreaterThan(0)
|
||||
expect(result.tools.length).toBeLessThanOrEqual(20) // default limit
|
||||
// summary entries should have name + description but not full schema
|
||||
expect(result.tools[0]).toHaveProperty('name')
|
||||
expect(result.tools[0]).toHaveProperty('description')
|
||||
expect(result.tools[0]).not.toHaveProperty('inputSchema')
|
||||
})
|
||||
|
||||
it('detail=name returns only names + scope', async () => {
|
||||
const result = await call({ detail: 'name', limit: 5 })
|
||||
expect(result.detail).toBe('name')
|
||||
for (const t of result.tools) {
|
||||
expect(t).not.toHaveProperty('description')
|
||||
expect(t).not.toHaveProperty('inputSchema')
|
||||
expect(t).toHaveProperty('name')
|
||||
expect(t).toHaveProperty('scope')
|
||||
}
|
||||
})
|
||||
|
||||
it('detail=full returns inputSchema and outputSchema', async () => {
|
||||
const result = await call({ detail: 'full', query: 'list_uncategorized', limit: 5 })
|
||||
expect(result.tools.length).toBeGreaterThan(0)
|
||||
const tool = result.tools[0]
|
||||
expect(tool).toHaveProperty('inputSchema')
|
||||
expect(tool).toHaveProperty('outputSchema')
|
||||
})
|
||||
|
||||
it('filters by query keyword', async () => {
|
||||
const result = await call({ query: 'vat' })
|
||||
expect(result.tools.length).toBeGreaterThan(0)
|
||||
for (const t of result.tools) {
|
||||
const haystack = `${t.name} ${t.description ?? ''}`.toLowerCase()
|
||||
expect(haystack).toContain('vat')
|
||||
}
|
||||
})
|
||||
|
||||
it('respects limit (1-50, default 20, clamps over-50)', async () => {
|
||||
const overLimit = await call({ limit: 100 })
|
||||
expect(overLimit.tools.length).toBeLessThanOrEqual(50)
|
||||
})
|
||||
|
||||
it('filters out tools the caller cannot invoke based on scopes', async () => {
|
||||
// Caller has only reports:read — should not see invoices:write tools.
|
||||
const result = await call({ query: '', limit: 50 }, ['reports:read'])
|
||||
const names = result.tools.map((t) => t.name)
|
||||
expect(names).not.toContain('gnubok_create_invoice')
|
||||
expect(names).not.toContain('gnubok_send_invoice')
|
||||
// But should see reports:read tools.
|
||||
expect(names).toContain('gnubok_get_trial_balance')
|
||||
// And unscoped tools (like search itself) are always available.
|
||||
expect(names).toContain('gnubok_search_tools')
|
||||
})
|
||||
|
||||
it('scope filter narrows results to a single scope', async () => {
|
||||
const result = await call({ scope: 'invoices:write', limit: 50 })
|
||||
for (const t of result.tools) {
|
||||
expect(t.scope).toBe('invoices:write')
|
||||
}
|
||||
})
|
||||
|
||||
it('total_matched reflects pre-limit candidates', async () => {
|
||||
const limited = await call({ query: '', limit: 3 })
|
||||
expect(limited.tools.length).toBe(3)
|
||||
expect(limited.total_matched).toBeGreaterThan(3)
|
||||
})
|
||||
|
||||
// Security: when the dispatcher fails to inject __keyScopes (rename, refactor
|
||||
// regression, direct invocation outside the dispatcher), the search MUST fall
|
||||
// back to a fail-closed default — only unscoped tools visible. The earlier
|
||||
// permissive default leaked the full inventory.
|
||||
it('fail-closed: hides scoped tools when __keyScopes is not injected', async () => {
|
||||
// Bypass the helper which always injects __keyScopes — call execute() directly.
|
||||
const result = (await searchTool.execute(
|
||||
{ limit: 50 }, // no __keyScopes
|
||||
'company-id',
|
||||
'user-id',
|
||||
{} as never,
|
||||
{ type: 'api_key' }
|
||||
)) as { tools: Array<{ name: string; scope: string | null }> }
|
||||
|
||||
const names = result.tools.map((t) => t.name)
|
||||
|
||||
// Only unscoped (discovery / skill) tools should appear.
|
||||
expect(names).toContain('gnubok_search_tools')
|
||||
expect(names).toContain('gnubok_list_skills')
|
||||
expect(names).toContain('gnubok_load_skill')
|
||||
|
||||
// No scoped tool should leak — pick representatives from each scope domain.
|
||||
expect(names).not.toContain('gnubok_create_invoice') // invoices:write
|
||||
expect(names).not.toContain('gnubok_get_trial_balance') // reports:read
|
||||
expect(names).not.toContain('gnubok_list_uncategorized_transactions') // transactions:read
|
||||
expect(names).not.toContain('gnubok_create_salary_run') // payroll:write
|
||||
expect(names).not.toContain('gnubok_close_period') // bookkeeping:write
|
||||
|
||||
// Sanity: every returned tool truly is unscoped.
|
||||
for (const t of result.tools) {
|
||||
expect(t.scope).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('fail-closed: explicitly empty __keyScopes also hides scoped tools', async () => {
|
||||
// The "scopes were checked, granted set is empty" case must behave the same
|
||||
// as "scopes were not injected at all". Both indicate no scoped access.
|
||||
const result = (await searchTool.execute(
|
||||
{ __keyScopes: [], limit: 50 },
|
||||
'company-id',
|
||||
'user-id',
|
||||
{} as never,
|
||||
{ type: 'api_key' }
|
||||
)) as { tools: Array<{ name: string; scope: string | null }> }
|
||||
|
||||
const names = result.tools.map((t) => t.name)
|
||||
expect(names).not.toContain('gnubok_create_invoice')
|
||||
expect(names).not.toContain('gnubok_get_trial_balance')
|
||||
for (const t of result.tools) {
|
||||
expect(t.scope).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Tests for skills over MCP — registry, discovery tools, and resource exposure.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { skills, findSkill, SKILL_URI_PREFIX, skillUri } from '../skills'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
// Minimal scopes — skills tools should be available regardless.
|
||||
scopes: [],
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
function mcpRequest(method: string, params?: Record<string, unknown>, id: number | string = 1): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
||||
})
|
||||
}
|
||||
|
||||
async function parseResult(response: Response) {
|
||||
const json = await response.json()
|
||||
return json.result
|
||||
}
|
||||
|
||||
describe('Skills registry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('exports a non-empty skills array', () => {
|
||||
expect(skills.length).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('every skill has unique slug', () => {
|
||||
const slugs = skills.map((s) => s.slug)
|
||||
expect(new Set(slugs).size).toBe(slugs.length)
|
||||
})
|
||||
|
||||
it('every skill body is non-trivial and contains a Tools section', () => {
|
||||
for (const s of skills) {
|
||||
expect(s.body.length, `skill ${s.slug} body length`).toBeGreaterThan(500)
|
||||
expect(s.body, `skill ${s.slug} should have a ## Tools section`).toMatch(/## Tools/i)
|
||||
}
|
||||
})
|
||||
|
||||
it('every skill has the expected metadata shape', () => {
|
||||
for (const s of skills) {
|
||||
expect(s.slug).toMatch(/^[a-z0-9-]+$/)
|
||||
expect(s.name).toBeTruthy()
|
||||
expect(s.summary.length).toBeGreaterThan(20)
|
||||
expect(s.summary.length).toBeLessThan(200)
|
||||
expect(Array.isArray(s.tags)).toBe(true)
|
||||
expect(s.tags.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('findSkill returns the skill or null', () => {
|
||||
expect(findSkill('month-end-close')).toBeTruthy()
|
||||
expect(findSkill('does-not-exist')).toBeNull()
|
||||
})
|
||||
|
||||
it('skillUri uses the gnubok://skill/ prefix', () => {
|
||||
expect(skillUri('foo')).toBe('gnubok://skill/foo')
|
||||
expect(SKILL_URI_PREFIX).toBe('gnubok://skill/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_list_skills tool', () => {
|
||||
it('is registered with correct annotations and no scope requirement', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool?.annotations.readOnlyHint).toBe(true)
|
||||
expect(tool?.annotations.idempotentHint).toBe(true)
|
||||
})
|
||||
|
||||
it('returns all skills when called with no args', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const result = (await tool.execute({}, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ slug: string; name: string; summary: string; tags: string[] }>
|
||||
count: number
|
||||
}
|
||||
expect(result.count).toBe(skills.length)
|
||||
expect(result.skills.every((s) => s.slug && s.name && s.summary)).toBe(true)
|
||||
// Body should NOT be returned by list (token saving).
|
||||
expect((result.skills[0] as Record<string, unknown>).body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('filters by tag', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_list_skills')!
|
||||
const result = (await tool.execute({ tag: 'vat' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as {
|
||||
skills: Array<{ slug: string; tags: string[] }>
|
||||
count: number
|
||||
}
|
||||
expect(result.count).toBeGreaterThan(0)
|
||||
for (const s of result.skills) {
|
||||
expect(s.tags.map((t) => t.toLowerCase())).toContain('vat')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_load_skill tool', () => {
|
||||
it('is registered', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')
|
||||
expect(tool).toBeDefined()
|
||||
})
|
||||
|
||||
it('returns full body for a valid slug', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
const result = (await tool.execute({ slug: 'month-end-close' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })) as {
|
||||
slug: string
|
||||
name: string
|
||||
body: string
|
||||
}
|
||||
expect(result.slug).toBe('month-end-close')
|
||||
expect(result.body).toContain('# Month-End Close')
|
||||
expect(result.body).toContain('## Tools')
|
||||
})
|
||||
|
||||
it('throws structured error for unknown slug', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
await expect(
|
||||
tool.execute({ slug: 'nonexistent-skill' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })
|
||||
).rejects.toThrow(/Skill not found.*Available skills/)
|
||||
})
|
||||
|
||||
it('throws when slug is missing or empty', async () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_load_skill')!
|
||||
await expect(
|
||||
tool.execute({ slug: '' }, 'company-1', 'user-1', {} as never, { type: 'api_key' })
|
||||
).rejects.toThrow(/slug is required/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skills via MCP protocol', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('resources/list includes one entry per skill at gnubok://skill/<slug>', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('resources/list'))
|
||||
const result = await parseResult(res)
|
||||
const uris = result.resources.map((r: { uri: string }) => r.uri)
|
||||
for (const skill of skills) {
|
||||
expect(uris).toContain(skillUri(skill.slug))
|
||||
}
|
||||
})
|
||||
|
||||
it('skill resources have the text/markdown mimeType', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('resources/list'))
|
||||
const result = await parseResult(res)
|
||||
const skillResources = result.resources.filter((r: { uri: string }) =>
|
||||
r.uri.startsWith(SKILL_URI_PREFIX)
|
||||
)
|
||||
expect(skillResources.length).toBe(skills.length)
|
||||
for (const r of skillResources) {
|
||||
expect(r.mimeType).toBe('text/markdown')
|
||||
}
|
||||
})
|
||||
|
||||
it('resources/read returns the Markdown body for a skill URI', async () => {
|
||||
const res = await handleMcpRequest(
|
||||
mcpRequest('resources/read', { uri: 'gnubok://skill/quarterly-vat-review' })
|
||||
)
|
||||
const result = await parseResult(res)
|
||||
expect(result.contents).toHaveLength(1)
|
||||
expect(result.contents[0].uri).toBe('gnubok://skill/quarterly-vat-review')
|
||||
expect(result.contents[0].mimeType).toBe('text/markdown')
|
||||
expect(result.contents[0].text).toContain('# Quarterly VAT Review')
|
||||
})
|
||||
|
||||
it('resources/read returns Resource not found for unknown skill slug', async () => {
|
||||
const res = await handleMcpRequest(
|
||||
mcpRequest('resources/read', { uri: 'gnubok://skill/does-not-exist' })
|
||||
)
|
||||
const json = await res.json()
|
||||
expect(json.error).toBeDefined()
|
||||
expect(json.error.message).toContain('Resource not found')
|
||||
})
|
||||
|
||||
it('tools/list includes both skill tools', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('tools/list'))
|
||||
const result = await parseResult(res)
|
||||
const names = result.tools.map((t: { name: string }) => t.name)
|
||||
expect(names).toContain('gnubok_list_skills')
|
||||
expect(names).toContain('gnubok_load_skill')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Tests for `mcp.tool_called` telemetry emission.
|
||||
*
|
||||
* Verifies all four dispatcher exit points (success, execution error,
|
||||
* scope denied, unknown tool) emit a correctly-shaped event to the bus,
|
||||
* and that the event-log handler registers the new type for persistence.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
// ── Mocks (mirrors receipt-matcher.test.ts setup) ────────────
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
// Only reports:read — enough to call gnubok_get_trial_balance, NOT enough
|
||||
// to call gnubok_create_invoice (invoices:write). Drives the scope-denied test.
|
||||
scopes: ['reports:read'],
|
||||
apiKeyId: 'key-1',
|
||||
apiKeyName: 'Test Key',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
function mcpRequest(method: string, params?: Record<string, unknown>, id: number | string = 1): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
||||
})
|
||||
}
|
||||
|
||||
interface ToolCalledPayload {
|
||||
tool: string
|
||||
requiredScope: string | null
|
||||
actorType: string
|
||||
actorId: string | null
|
||||
actorLabel: string | null
|
||||
latencyMs: number
|
||||
success: boolean
|
||||
isError: boolean
|
||||
errorCode: string | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
companyId: string
|
||||
}
|
||||
|
||||
interface ToolsListCalledPayload {
|
||||
toolCount: number
|
||||
actorType: string
|
||||
actorId: string | null
|
||||
actorLabel: string | null
|
||||
latencyMs: number
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
companyId: string
|
||||
}
|
||||
|
||||
interface ResourceReadPayload {
|
||||
uri: string
|
||||
kind: 'widget' | 'skill' | 'data' | 'unknown'
|
||||
success: boolean
|
||||
errorCode: string | null
|
||||
latencyMs: number
|
||||
actorType: string
|
||||
actorId: string | null
|
||||
actorLabel: string | null
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
companyId: string
|
||||
}
|
||||
|
||||
async function captureNextToolCalledEvent(): Promise<ToolCalledPayload> {
|
||||
return new Promise<ToolCalledPayload>((resolve) => {
|
||||
const off = eventBus.on('mcp.tool_called', (payload) => {
|
||||
off()
|
||||
resolve(payload as ToolCalledPayload)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function captureNextToolsListEvent(): Promise<ToolsListCalledPayload> {
|
||||
return new Promise<ToolsListCalledPayload>((resolve) => {
|
||||
const off = eventBus.on('mcp.tools_list_called', (payload) => {
|
||||
off()
|
||||
resolve(payload as ToolsListCalledPayload)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function captureNextResourceReadEvent(): Promise<ResourceReadPayload> {
|
||||
return new Promise<ResourceReadPayload>((resolve) => {
|
||||
const off = eventBus.on('mcp.resource_read', (payload) => {
|
||||
off()
|
||||
resolve(payload as ResourceReadPayload)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('mcp.tool_called telemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('emits on successful tool execution with success=true and a measured latencyMs', async () => {
|
||||
const eventPromise = captureNextToolCalledEvent()
|
||||
|
||||
// gnubok_list_skills is unscoped + has no DB dependency, perfect for a happy-path test.
|
||||
const response = await handleMcpRequest(
|
||||
mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} })
|
||||
)
|
||||
const json = await response.json()
|
||||
expect(json.error).toBeUndefined()
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.tool).toBe('gnubok_list_skills')
|
||||
expect(event.requiredScope).toBeNull() // unscoped
|
||||
expect(event.success).toBe(true)
|
||||
expect(event.isError).toBe(false)
|
||||
expect(event.errorCode).toBeNull()
|
||||
expect(event.errorKind).toBeNull()
|
||||
expect(event.actorType).toBe('api_key')
|
||||
expect(event.actorId).toBe('key-1')
|
||||
expect(event.actorLabel).toBe('Test Key')
|
||||
expect(event.userId).toBe('user-1')
|
||||
expect(event.companyId).toBe('company-1')
|
||||
expect(event.requestId).toBe(1)
|
||||
// Real wall-clock — non-negative number
|
||||
expect(typeof event.latencyMs).toBe('number')
|
||||
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('emits errorKind=scope_denied when the API key lacks the required scope', async () => {
|
||||
const eventPromise = captureNextToolCalledEvent()
|
||||
|
||||
// gnubok_create_invoice requires invoices:write; our test key has only reports:read.
|
||||
await handleMcpRequest(
|
||||
mcpRequest('tools/call', {
|
||||
name: 'gnubok_create_invoice',
|
||||
arguments: { customer_id: 'x', items: [] },
|
||||
})
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.tool).toBe('gnubok_create_invoice')
|
||||
expect(event.requiredScope).toBe('invoices:write')
|
||||
expect(event.success).toBe(false)
|
||||
expect(event.isError).toBe(true)
|
||||
expect(event.errorKind).toBe('scope_denied')
|
||||
expect(event.errorCode).toBe('INSUFFICIENT_SCOPE')
|
||||
// Scope denial exits before tool.execute() runs.
|
||||
expect(event.latencyMs).toBe(0)
|
||||
})
|
||||
|
||||
it('emits errorKind=unknown_tool when the tool name does not exist', async () => {
|
||||
const eventPromise = captureNextToolCalledEvent()
|
||||
|
||||
await handleMcpRequest(
|
||||
mcpRequest('tools/call', { name: 'gnubok_does_not_exist', arguments: {} })
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.tool).toBe('gnubok_does_not_exist')
|
||||
expect(event.requiredScope).toBeNull()
|
||||
expect(event.success).toBe(false)
|
||||
expect(event.isError).toBe(true)
|
||||
expect(event.errorKind).toBe('unknown_tool')
|
||||
expect(event.errorCode).toBe('UNKNOWN_TOOL')
|
||||
expect(event.latencyMs).toBe(0)
|
||||
})
|
||||
|
||||
it('emits errorKind=execution when the tool throws inside execute()', async () => {
|
||||
const eventPromise = captureNextToolCalledEvent()
|
||||
|
||||
// gnubok_load_skill throws on unknown slug — clean way to force an
|
||||
// execution error without mocking Supabase.
|
||||
await handleMcpRequest(
|
||||
mcpRequest('tools/call', {
|
||||
name: 'gnubok_load_skill',
|
||||
arguments: { slug: 'definitely-does-not-exist' },
|
||||
})
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.tool).toBe('gnubok_load_skill')
|
||||
expect(event.success).toBe(false)
|
||||
expect(event.isError).toBe(true)
|
||||
expect(event.errorKind).toBe('execution')
|
||||
expect(event.errorCode).toBeTruthy()
|
||||
// Execution path measures real latency, even if the tool exits quickly.
|
||||
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('does NOT block the JSON-RPC response on telemetry — even if a handler throws', async () => {
|
||||
// Register a handler that throws synchronously. The bus already isolates
|
||||
// failures via Promise.allSettled, so the response should still arrive.
|
||||
eventBus.on('mcp.tool_called', () => {
|
||||
throw new Error('intentional handler boom')
|
||||
})
|
||||
|
||||
const response = await handleMcpRequest(
|
||||
mcpRequest('tools/call', { name: 'gnubok_list_skills', arguments: {} })
|
||||
)
|
||||
const json = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(json.error).toBeUndefined()
|
||||
expect(json.result).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mcp.tools_list_called telemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('emits with toolCount filtered by the caller scopes', async () => {
|
||||
const eventPromise = captureNextToolsListEvent()
|
||||
|
||||
await handleMcpRequest(mcpRequest('tools/list'))
|
||||
|
||||
const event = await eventPromise
|
||||
// Caller has only reports:read — tools requiring other scopes are filtered out,
|
||||
// but unscoped tools (search_tools, list_skills, load_skill) and reports:read
|
||||
// tools are present. Just sanity-check the count is positive and bounded.
|
||||
expect(event.toolCount).toBeGreaterThan(0)
|
||||
expect(event.toolCount).toBeLessThan(100)
|
||||
expect(event.actorType).toBe('api_key')
|
||||
expect(event.userId).toBe('user-1')
|
||||
expect(event.companyId).toBe('company-1')
|
||||
expect(typeof event.latencyMs).toBe('number')
|
||||
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mcp.resource_read telemetry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('emits kind=widget for a widget URI hit', async () => {
|
||||
const eventPromise = captureNextResourceReadEvent()
|
||||
|
||||
await handleMcpRequest(
|
||||
mcpRequest('resources/read', { uri: 'ui://receipt-matcher/app.html' })
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.uri).toBe('ui://receipt-matcher/app.html')
|
||||
expect(event.kind).toBe('widget')
|
||||
expect(event.success).toBe(true)
|
||||
expect(event.errorCode).toBeNull()
|
||||
})
|
||||
|
||||
it('emits kind=skill for a skill URI hit', async () => {
|
||||
const eventPromise = captureNextResourceReadEvent()
|
||||
|
||||
await handleMcpRequest(
|
||||
mcpRequest('resources/read', { uri: 'gnubok://skill/quarterly-vat-review' })
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.uri).toBe('gnubok://skill/quarterly-vat-review')
|
||||
expect(event.kind).toBe('skill')
|
||||
expect(event.success).toBe(true)
|
||||
expect(event.errorCode).toBeNull()
|
||||
})
|
||||
|
||||
it('emits kind=unknown success=false for an URI that matches nothing', async () => {
|
||||
const eventPromise = captureNextResourceReadEvent()
|
||||
|
||||
await handleMcpRequest(
|
||||
mcpRequest('resources/read', { uri: 'gnubok://nonexistent/whatever' })
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.uri).toBe('gnubok://nonexistent/whatever')
|
||||
expect(event.kind).toBe('unknown')
|
||||
expect(event.success).toBe(false)
|
||||
expect(event.errorCode).toBe('RESOURCE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('emits kind=unknown for a skill URI with an unknown slug', async () => {
|
||||
const eventPromise = captureNextResourceReadEvent()
|
||||
|
||||
// The dispatcher only matches kind=skill when findSkill returns a hit;
|
||||
// unknown slugs fall through and end up as kind=unknown.
|
||||
await handleMcpRequest(
|
||||
mcpRequest('resources/read', { uri: 'gnubok://skill/does-not-exist' })
|
||||
)
|
||||
|
||||
const event = await eventPromise
|
||||
expect(event.kind).toBe('unknown')
|
||||
expect(event.success).toBe(false)
|
||||
expect(event.errorCode).toBe('RESOURCE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('event_log persistence registration', () => {
|
||||
it('includes all three MCP telemetry events in the persisted event types', async () => {
|
||||
// Read the file as text — the constant is module-private. This is a
|
||||
// deliberate string-level guard so a future refactor that drops one
|
||||
// of the events from the list trips the test.
|
||||
const fs = await import('node:fs/promises')
|
||||
const path = await import('node:path')
|
||||
const handlerPath = path.resolve(__dirname, '..', '..', '..', '..', 'lib', 'events', 'handlers', 'event-log-handler.ts')
|
||||
const text = await fs.readFile(handlerPath, 'utf-8')
|
||||
expect(text).toMatch(/'mcp\.tool_called'/)
|
||||
expect(text).toMatch(/'mcp\.tools_list_called'/)
|
||||
expect(text).toMatch(/'mcp\.resource_read'/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Focused tests for computeVatReport — the shared VAT computation used by
|
||||
* gnubok_get_vat_report and gnubok_vat_review_widget. These exist because the
|
||||
* tools/call integration tests can't reach into the rutor math; this file
|
||||
* mocks Supabase to feed synthetic journal entry lines and asserts the rutor
|
||||
* shape, ruta48 inclusion of 2647, ruta49 formula, and the one-sided
|
||||
* reverse-charge warning.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeVatReport, tools } from '../server'
|
||||
|
||||
interface MockLine {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}
|
||||
|
||||
function mockSupabaseWithLines(lines: MockLine[]) {
|
||||
// Build a chain that matches the call path in computeVatReport:
|
||||
// .from('journal_entry_lines').select(...).eq(...).in(...).gte(...).lte(...)
|
||||
// The terminal `.lte()` returns `{ data, error }`.
|
||||
const terminal = { data: lines, error: null }
|
||||
const chain: Record<string, () => unknown> = {}
|
||||
// Terminal awaitable: vitest awaits the last call; .lte() returns the data.
|
||||
chain.lte = () => terminal
|
||||
chain.gte = () => chain
|
||||
chain.in = () => chain
|
||||
chain.eq = () => chain
|
||||
chain.select = () => chain
|
||||
chain.from = () => chain
|
||||
return { from: chain.from } as never
|
||||
}
|
||||
|
||||
describe('computeVatReport', () => {
|
||||
it('aggregates 2611 → ruta10, 2641 → ruta48, includes 2647 → ruta48', async () => {
|
||||
const lines: MockLine[] = [
|
||||
// Domestic 25% sale: 1000 + 250 VAT
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 250 },
|
||||
// Domestic input VAT 25%
|
||||
{ account_number: '2641', debit_amount: 100, credit_amount: 0 },
|
||||
// Domestic reverse-charge input VAT (2647)
|
||||
{ account_number: '2647', debit_amount: 50, credit_amount: 0 },
|
||||
]
|
||||
|
||||
const result = await computeVatReport(
|
||||
{ period_type: 'monthly', year: 2026, period: 1 },
|
||||
'company-1',
|
||||
mockSupabaseWithLines(lines)
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(1000)
|
||||
expect(result.rutor.ruta10).toBe(250)
|
||||
expect(result.rutor.ruta11).toBe(0)
|
||||
expect(result.rutor.ruta12).toBe(0)
|
||||
// Ruta 48 = 2641 (100) + 2647 (50) = 150
|
||||
expect(result.rutor.ruta48).toBe(150)
|
||||
// Ruta 49 = 250 - 150 = 100 (positive = pay)
|
||||
expect(result.rutor.ruta49).toBe(100)
|
||||
expect(result.summary).toContain('Moms att betala')
|
||||
expect(result.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('aggregates reverse-charge output VAT into ruta30/31/32 and the ruta49 formula', async () => {
|
||||
const lines: MockLine[] = [
|
||||
// Reverse-charge purchase 25% — both sides booked correctly
|
||||
{ account_number: '2614', debit_amount: 0, credit_amount: 500 }, // ruta30
|
||||
{ account_number: '2645', debit_amount: 500, credit_amount: 0 }, // matching input → ruta48
|
||||
// Reverse-charge purchase 6%
|
||||
{ account_number: '2634', debit_amount: 0, credit_amount: 30 }, // ruta32
|
||||
{ account_number: '2645', debit_amount: 30, credit_amount: 0 },
|
||||
]
|
||||
|
||||
const result = await computeVatReport(
|
||||
{ period_type: 'quarterly', year: 2026, period: 1 },
|
||||
'company-1',
|
||||
mockSupabaseWithLines(lines)
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta30).toBe(500)
|
||||
expect(result.rutor.ruta31).toBe(0)
|
||||
expect(result.rutor.ruta32).toBe(30)
|
||||
expect(result.rutor.ruta48).toBe(530) // 500 + 30 from 2645
|
||||
// Ruta 49 = (10+11+12+30+31+32) - 48 = 0+0+0+500+0+30 - 530 = 0
|
||||
expect(result.rutor.ruta49).toBe(0)
|
||||
expect(result.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('emits a one-sided-reverse-charge warning when 2614 is booked without 2645 OR 2647', async () => {
|
||||
const lines: MockLine[] = [
|
||||
// Output booked but matching input missing (the most common reverse-charge error)
|
||||
{ account_number: '2614', debit_amount: 0, credit_amount: 500 },
|
||||
// Neither 2645 nor 2647 present
|
||||
]
|
||||
|
||||
const result = await computeVatReport(
|
||||
{ period_type: 'monthly', year: 2026, period: 1 },
|
||||
'company-1',
|
||||
mockSupabaseWithLines(lines)
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta30).toBe(500)
|
||||
expect(result.rutor.ruta48).toBe(0)
|
||||
// Without the matching input, ruta49 is inflated by 500 — the warning surfaces this.
|
||||
expect(result.rutor.ruta49).toBe(500)
|
||||
expect(result.warnings.length).toBe(1)
|
||||
expect(result.warnings[0]).toMatch(/Omvänd betalningsskyldighet/)
|
||||
// Both 2645 (EU) and 2647 (domestic) are mentioned so users know what to look for.
|
||||
expect(result.warnings[0]).toMatch(/2645/)
|
||||
expect(result.warnings[0]).toMatch(/2647/)
|
||||
})
|
||||
|
||||
it('does NOT warn when reverse-charge output is balanced by 2647 (domestic, no 2645)', async () => {
|
||||
// Domestic reverse charge per ML 16:13 (byggtjänster, electronics > 100k SEK) —
|
||||
// matching input lands on 2647, not 2645. The earlier check missed this.
|
||||
const lines: MockLine[] = [
|
||||
{ account_number: '2614', debit_amount: 0, credit_amount: 500 }, // ruta30
|
||||
{ account_number: '2647', debit_amount: 500, credit_amount: 0 }, // domestic input → ruta48
|
||||
]
|
||||
|
||||
const result = await computeVatReport(
|
||||
{ period_type: 'monthly', year: 2026, period: 1 },
|
||||
'company-1',
|
||||
mockSupabaseWithLines(lines)
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta30).toBe(500)
|
||||
expect(result.rutor.ruta48).toBe(500)
|
||||
expect(result.rutor.ruta49).toBe(0)
|
||||
// No warning — the domestic mirror is correctly booked.
|
||||
expect(result.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('expanded ruta05 includes alternative BAS revenue accounts (3041/3051/3071) AND taxable EU goods (3106)', async () => {
|
||||
const lines: MockLine[] = [
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
{ account_number: '3041', debit_amount: 0, credit_amount: 500 }, // service 25%
|
||||
{ account_number: '3051', debit_amount: 0, credit_amount: 300 }, // goods 25%
|
||||
{ account_number: '3071', debit_amount: 0, credit_amount: 200 }, // other domestic
|
||||
{ account_number: '3106', debit_amount: 0, credit_amount: 100 }, // momspliktig EU goods
|
||||
]
|
||||
|
||||
const result = await computeVatReport(
|
||||
{ period_type: 'yearly', year: 2026, period: 1 },
|
||||
'company-1',
|
||||
mockSupabaseWithLines(lines)
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(2100)
|
||||
})
|
||||
|
||||
it('excludes 3004 (momsfri) from ruta05 — exempt sales must NOT be in the taxable base', async () => {
|
||||
const lines: MockLine[] = [
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
{ account_number: '3004', debit_amount: 0, credit_amount: 500 }, // exempt — must be excluded
|
||||
]
|
||||
|
||||
const result = await computeVatReport(
|
||||
{ period_type: 'yearly', year: 2026, period: 1 },
|
||||
'company-1',
|
||||
mockSupabaseWithLines(lines)
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(1000)
|
||||
})
|
||||
|
||||
it('aggregates 3108 → ruta35 (EU intra-community goods, momsfri leverans till EU)', async () => {
|
||||
const lines: MockLine[] = [
|
||||
// Domestic taxable sale
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
// EU goods supply, momsfri (zero-rated to EU customer with valid VAT number)
|
||||
{ account_number: '3108', debit_amount: 0, credit_amount: 5000 },
|
||||
]
|
||||
|
||||
const result = await computeVatReport(
|
||||
{ period_type: 'quarterly', year: 2026, period: 1 },
|
||||
'company-1',
|
||||
mockSupabaseWithLines(lines)
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(1000) // 3108 NOT in ruta05 (it's reported separately)
|
||||
expect(result.rutor.ruta35).toBe(5000) // The new ruta we just added
|
||||
expect(result.rutor.ruta39).toBe(0)
|
||||
expect(result.rutor.ruta40).toBe(0)
|
||||
})
|
||||
|
||||
it('refund summary string when ruta49 is negative', async () => {
|
||||
const lines: MockLine[] = [
|
||||
{ account_number: '2641', debit_amount: 100, credit_amount: 0 },
|
||||
// No output VAT; pure refund position.
|
||||
]
|
||||
|
||||
const result = await computeVatReport(
|
||||
{ period_type: 'monthly', year: 2026, period: 1 },
|
||||
'company-1',
|
||||
mockSupabaseWithLines(lines)
|
||||
)
|
||||
|
||||
expect(result.rutor.ruta49).toBe(-100)
|
||||
expect(result.summary).toContain('Moms att få tillbaka')
|
||||
})
|
||||
|
||||
it('exposes a rich outputSchema on both VAT tools (not bare {type:object})', () => {
|
||||
for (const name of ['gnubok_get_vat_report', 'gnubok_vat_review_widget']) {
|
||||
const tool = tools.find((t) => t.name === name)
|
||||
expect(tool, `tool ${name}`).toBeDefined()
|
||||
const schema = tool!.outputSchema as Record<string, unknown> | undefined
|
||||
expect(schema).toBeDefined()
|
||||
expect(schema!.type).toBe('object')
|
||||
const props = schema!.properties as Record<string, unknown>
|
||||
// The schema must declare period, period_label, rutor, summary, warnings.
|
||||
expect(props).toHaveProperty('period')
|
||||
expect(props).toHaveProperty('rutor')
|
||||
expect(props).toHaveProperty('summary')
|
||||
expect(props).toHaveProperty('warnings')
|
||||
// rutor must declare each ruta the runtime returns.
|
||||
const rutorProps = (props.rutor as { properties: Record<string, unknown> }).properties
|
||||
for (const r of ['ruta05', 'ruta10', 'ruta11', 'ruta12', 'ruta30', 'ruta31', 'ruta32', 'ruta35', 'ruta39', 'ruta40', 'ruta48', 'ruta49']) {
|
||||
expect(rutorProps, `tool ${name} rutor.${r}`).toHaveProperty(r)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects bad period_type / out-of-range period / out-of-range year', async () => {
|
||||
const supabase = mockSupabaseWithLines([])
|
||||
|
||||
await expect(
|
||||
computeVatReport({ period_type: 'weekly', year: 2026, period: 1 }, 'c', supabase)
|
||||
).rejects.toThrow(/period_type/)
|
||||
|
||||
await expect(
|
||||
computeVatReport({ period_type: 'monthly', year: 2026, period: 13 }, 'c', supabase)
|
||||
).rejects.toThrow(/period must be 1–12/)
|
||||
|
||||
await expect(
|
||||
computeVatReport({ period_type: 'quarterly', year: 2026, period: 5 }, 'c', supabase)
|
||||
).rejects.toThrow(/period must be 1–4/)
|
||||
|
||||
await expect(
|
||||
computeVatReport({ period_type: 'monthly', year: 1900, period: 1 }, 'c', supabase)
|
||||
).rejects.toThrow(/year must be between/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Tests for the VAT review widget — registration, resource serving,
|
||||
* and tool _meta wiring. Does NOT re-test the underlying VAT computation
|
||||
* (covered by existing get_vat_report tests); only the widget plumbing.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { tools } from '../server'
|
||||
import { uiWidgets, findUiWidget } from '../widgets'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
scopes: ['reports:read'],
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
function mcpRequest(method: string, params?: Record<string, unknown>, id: number | string = 1): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
||||
})
|
||||
}
|
||||
|
||||
async function parseResult(response: Response) {
|
||||
const json = await response.json()
|
||||
return json.result
|
||||
}
|
||||
|
||||
describe('VAT review widget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('widget registration', () => {
|
||||
it('registers the vat-review widget in uiWidgets', () => {
|
||||
const widget = findUiWidget('ui://vat-review/app.html')
|
||||
expect(widget).toBeDefined()
|
||||
expect(widget?.name).toBe('VAT Review')
|
||||
expect(widget?.html).toContain('<!DOCTYPE html>')
|
||||
expect(widget?.html).toContain('Momsdeklaration')
|
||||
})
|
||||
|
||||
it('uiWidgets contains both receipt-matcher and vat-review', () => {
|
||||
const uris = uiWidgets.map((w) => w.uri)
|
||||
expect(uris).toContain('ui://receipt-matcher/app.html')
|
||||
expect(uris).toContain('ui://vat-review/app.html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_vat_review_widget tool', () => {
|
||||
it('is registered with _meta.ui pointing to the vat-review widget', () => {
|
||||
const tool = tools.find((t) => t.name === 'gnubok_vat_review_widget')
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool?._meta).toEqual({ ui: { resourceUri: 'ui://vat-review/app.html' } })
|
||||
expect(tool?.annotations.readOnlyHint).toBe(true)
|
||||
})
|
||||
|
||||
it('declares the same required inputs as gnubok_get_vat_report', () => {
|
||||
const widgetTool = tools.find((t) => t.name === 'gnubok_vat_review_widget')
|
||||
const reportTool = tools.find((t) => t.name === 'gnubok_get_vat_report')
|
||||
const widgetRequired = (widgetTool?.inputSchema as { required?: string[] }).required ?? []
|
||||
const reportRequired = (reportTool?.inputSchema as { required?: string[] }).required ?? []
|
||||
expect(widgetRequired.sort()).toEqual(reportRequired.sort())
|
||||
})
|
||||
})
|
||||
|
||||
describe('protocol: resources/list', () => {
|
||||
it('lists the vat-review widget alongside the receipt-matcher widget', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('resources/list'))
|
||||
const result = await parseResult(res)
|
||||
|
||||
const widget = result.resources.find(
|
||||
(r: { uri: string }) => r.uri === 'ui://vat-review/app.html'
|
||||
)
|
||||
expect(widget).toEqual({
|
||||
uri: 'ui://vat-review/app.html',
|
||||
name: 'VAT Review',
|
||||
description: 'Interactive review of momsdeklaration (SKV 4700) before filing',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
})
|
||||
|
||||
const uris = result.resources.map((r: { uri: string }) => r.uri)
|
||||
expect(uris).toContain('ui://receipt-matcher/app.html')
|
||||
expect(uris).toContain('ui://vat-review/app.html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('protocol: resources/read', () => {
|
||||
it('returns HTML for the vat-review widget', async () => {
|
||||
const res = await handleMcpRequest(
|
||||
mcpRequest('resources/read', { uri: 'ui://vat-review/app.html' })
|
||||
)
|
||||
const result = await parseResult(res)
|
||||
|
||||
expect(result.contents).toHaveLength(1)
|
||||
expect(result.contents[0].uri).toBe('ui://vat-review/app.html')
|
||||
expect(result.contents[0].mimeType).toBe('text/html;profile=mcp-app')
|
||||
expect(result.contents[0].text).toContain('Momsdeklaration')
|
||||
expect(result.contents[0].text).toContain('ruta49')
|
||||
})
|
||||
})
|
||||
|
||||
describe('protocol: tools/list', () => {
|
||||
it('includes gnubok_vat_review_widget with _meta when the API key has reports:read scope', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('tools/list'))
|
||||
const result = await parseResult(res)
|
||||
|
||||
const widgetTool = result.tools.find(
|
||||
(t: { name: string }) => t.name === 'gnubok_vat_review_widget'
|
||||
)
|
||||
expect(widgetTool).toBeDefined()
|
||||
expect(widgetTool._meta).toEqual({ ui: { resourceUri: 'ui://vat-review/app.html' } })
|
||||
})
|
||||
})
|
||||
})
|
||||
+1042
-583
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
import type { Skill } from './types'
|
||||
import { monthEndCloseSkill } from './month-end-close'
|
||||
import { quarterlyVatReviewSkill } from './quarterly-vat-review'
|
||||
import { yearEndCloseSkill } from './year-end-close'
|
||||
import { invoicingRulesSkill } from './invoicing-rules'
|
||||
import { payrollMonthlySkill } from './payroll-monthly'
|
||||
|
||||
export const skills: Skill[] = [
|
||||
monthEndCloseSkill,
|
||||
quarterlyVatReviewSkill,
|
||||
yearEndCloseSkill,
|
||||
invoicingRulesSkill,
|
||||
payrollMonthlySkill,
|
||||
]
|
||||
|
||||
export function findSkill(slug: string): Skill | null {
|
||||
return skills.find((s) => s.slug === slug) ?? null
|
||||
}
|
||||
|
||||
export type { Skill } from './types'
|
||||
export { SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './types'
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Invoicing Rules — gnubok
|
||||
|
||||
How to send a Swedish-compliant invoice from start to finish.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Skicka faktura till [kund]"
|
||||
- "Invoice [customer] for [amount]"
|
||||
- "Create a credit note"
|
||||
- "How do I invoice an EU customer?"
|
||||
|
||||
## Mandatory invoice fields (ML 17 kap. 24 §)
|
||||
|
||||
Every Swedish invoice (faktura) must contain:
|
||||
|
||||
1. **Datum för utfärdande** (issue date)
|
||||
2. **Löpnummer** (sequential invoice number — system-assigned at approval)
|
||||
3. **Säljarens momsregistreringsnummer** (seller's VAT number)
|
||||
4. **Köparens momsregistreringsnummer** (for EU B2B; otherwise name + address)
|
||||
5. **Säljarens fullständiga namn och adress**
|
||||
6. **Köparens fullständiga namn och adress**
|
||||
7. **Mängd och slag av varor / omfattning av tjänster**
|
||||
8. **Datum då varorna levererats / tjänsterna utförts** (if different from invoice date)
|
||||
9. **Beskattningsunderlag per momssats**
|
||||
10. **Tillämpad momssats**
|
||||
11. **Momsbelopp**
|
||||
12. **Vid omvänd betalningsskyldighet:** notation "omvänd betalningsskyldighet" or "reverse charge"
|
||||
13. **Vid undantag:** referens till relevant ML-paragraph or article in Direktivet
|
||||
14. **F-skatt / FA-skatt notation** ("Innehar F-skattsedel" or "F-skattebevis") for B2B services
|
||||
|
||||
The \`gnubok_create_invoice\` tool handles all of these automatically — but always provide \`our_reference\`/\`your_reference\` if known.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Customer ready
|
||||
|
||||
Customers with their full data already in the system: \`gnubok_list_customers\`. Find the one. Note the \`customer_id\`.
|
||||
|
||||
If the customer doesn't exist:
|
||||
|
||||
\`gnubok_create_customer\` with at minimum \`{ name, customer_type }\`. \`customer_type\` must be one of:
|
||||
|
||||
- \`individual\` — physical person
|
||||
- \`swedish_business\` — AB / HB / KB / EF with Swedish org-number
|
||||
- \`eu_business\` — EU company. **Provide \`vat_number\`** so VIES validation runs (otherwise reverse-charge eligibility fails).
|
||||
- \`non_eu_business\` — outside EU
|
||||
|
||||
### Step 2 — Determine VAT treatment
|
||||
|
||||
| Customer | VAT treatment | Default rate |
|
||||
|----------|---------------|--------------|
|
||||
| Swedish individual | \`standard_25\` (or 12/6 by goods) | 25 % |
|
||||
| Swedish business | \`standard_25\` | 25 % |
|
||||
| EU business with valid VAT number | \`reverse_charge\` | 0 % (with notation) |
|
||||
| EU business without VAT number | \`standard_25\` | 25 % (treat as B2C) |
|
||||
| Non-EU business / private | \`export\` | 0 % |
|
||||
| Books, newspapers, transport | \`reduced_6\` | 6 % |
|
||||
| Restaurant, hotel | \`reduced_12\` | 12 % — see footnote below |
|
||||
|
||||
**Footnote on the 1 April 2026 livsmedel rate change** (Prop. 2025/26:55):
|
||||
|
||||
- **Livsmedel sold in other forms** (grocery, takeaway sold by retailer, etc.) drops from 12 % → **6 %** from 1 April 2026.
|
||||
- **Restaurang och servering** (sit-down food and beverage service) **stays at 12 %** even after 1 April 2026.
|
||||
- Hotels: room nights remain at 12 %; on-site restaurant service is restaurang (12 %); minibar / shop is sale of varor (6 % if food, 25 % otherwise).
|
||||
|
||||
When in doubt for an invoice issued on or after 1 April 2026, classify the supply per the above rather than defaulting to one rate for "restaurang/hotell".
|
||||
|
||||
Use \`getAvailableVatRates(customerType, vatNumberValidated)\` semantics — gnubok handles this. Per-line override possible via \`vat_rate\` on each item.
|
||||
|
||||
### Step 3 — Create the invoice
|
||||
|
||||
\`gnubok_create_invoice({ customer_id, items: [{ description, quantity, unit, unit_price, vat_rate? }], invoice_date?, due_date?, currency? })\`
|
||||
|
||||
Returns staged operation. User approves in web app → invoice number is allocated atomically (gap-free) and journal entry posted (under accrual / faktureringsmetoden).
|
||||
|
||||
### Step 4 — Send
|
||||
|
||||
\`gnubok_send_invoice(invoice_id)\` — emails the PDF to the customer. Requires email service configured (Resend) and customer email on file.
|
||||
|
||||
If the user delivered the invoice manually (printed, e-faktura via Peppol, etc.), use \`gnubok_mark_invoice_as_sent\` instead — same booking effect, no email.
|
||||
|
||||
### Step 5 — Record payment
|
||||
|
||||
When money arrives in 1930:
|
||||
|
||||
- **Match to bank transaction** (preferred): \`gnubok_match_transaction_to_invoice({ transaction_id, invoice_id })\` — links the payment, marks invoice paid (or partially_paid), books JE.
|
||||
- **Manual mark**: \`gnubok_mark_invoice_as_paid({ invoice_id, payment_date })\` — when payment arrived but isn't in the bank feed yet.
|
||||
|
||||
### Step 6 — Reverse if needed
|
||||
|
||||
If the invoice was wrong: \`gnubok_credit_invoice({ invoice_id, reason })\` creates a \`KR-\` mirror invoice with negated amounts and reverses the original JE. Original status → \`credited\`. **Never edit a sent invoice** — kreditfaktura is the only legal path.
|
||||
|
||||
The kreditfaktura **itself** consumes a sequential number from the same (or a dedicated KR-) fakturaserie per BFL 5 kap. 6–7 § / ML 17 kap. 22–23 §. The \`KR-\` prefix is a display convention; the underlying löpnummer must be unbroken just like the regular invoice series. \`gnubok_credit_invoice\` allocates this atomically at approval — agents shouldn't try to set or skip the number manually.
|
||||
|
||||
## ROT/RUT (consumer services)
|
||||
|
||||
For consumer-targeted services (RUT: städning, RUT) or construction (ROT):
|
||||
|
||||
- Use \`fakturamodellen\` (the customer pays the discounted amount; you reclaim the rest from Skatteverket)
|
||||
- Customer must have **personnummer** (or coordination number) on file
|
||||
- Add the property's **fastighetsbeteckning** (real estate ID) for ROT
|
||||
- **RUT**: 50 % deduction, max 75 000 SEK/year/person (2025).
|
||||
- **ROT**: rate and ceiling have shifted year by year — verify against Skatteverket for the invoice date before applying:
|
||||
- **Standard rate**: 30 %, max 50 000 SEK/year/person.
|
||||
- **2024 H2 (1 Jul – 31 Dec 2024)**: temporary doubled ceiling, separate caps applied.
|
||||
- **2025 May–Dec**: enhanced 50 % rate (still 50 000 SEK ceiling). Reverts to 30 % from 2026 unless extended.
|
||||
- When in doubt for an invoice issued in May 2025 or later, default to the current Skatteverket-published rate rather than the 30 % baseline.
|
||||
|
||||
This data goes on the invoice; gnubok's invoice template renders it automatically when set on the customer.
|
||||
|
||||
## Peppol / e-invoicing (B2G)
|
||||
|
||||
Swedish authorities require e-invoices via Peppol BIS Billing 3.0 (Lag 2018:1277). For private B2B, the buyer's preference governs but Peppol is preferred. gnubok renders an EN 16931-compliant XML on demand.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Invoice numbers are sequential and gap-free.** Allocated atomically at approval. If you change your mind, use \`gnubok_credit_invoice\`, never delete or skip a number — Skatteverket will audit.
|
||||
- **F-skatt notation is mandatory** for B2B services. gnubok adds it automatically when company settings have F-skatt = true.
|
||||
- **Currency:** SEK is default but the invoice itself can be issued in any of SEK/EUR/USD/GBP/NOK/DKK. The bookkeeping JE is always in SEK at issue-date Riksbanken rate.
|
||||
|
||||
## Common errors
|
||||
|
||||
- **EU customer charged 25 %**: missing \`vat_number\` or VIES validation failed. Fix: re-validate, then re-issue as \`reverse_charge\`.
|
||||
- **Sent before approval**: not possible — \`gnubok_send_invoice\` stages too. The user must approve.
|
||||
- **Edit instead of credit**: blocked by DB triggers. Use \`gnubok_credit_invoice\`.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_list_customers\` / \`gnubok_create_customer\` — customer setup
|
||||
- \`gnubok_create_invoice\` — stage new invoice
|
||||
- \`gnubok_send_invoice\` — email PDF
|
||||
- \`gnubok_mark_invoice_as_sent\` — manual delivery
|
||||
- \`gnubok_mark_invoice_as_paid\` — manual payment
|
||||
- \`gnubok_match_transaction_to_invoice\` — link bank payment
|
||||
- \`gnubok_credit_invoice\` — kreditfaktura (legal undo)
|
||||
- \`gnubok_convert_invoice\` — proforma → real invoice
|
||||
- \`gnubok_list_invoices\` — find existing invoices
|
||||
`
|
||||
|
||||
export const invoicingRulesSkill: Skill = {
|
||||
slug: 'invoicing-rules',
|
||||
name: 'Invoicing Rules',
|
||||
summary: 'Mandatory invoice fields (ML 17 kap. 24 §), VAT treatment per customer type, ROT/RUT, Peppol, kreditfaktura.',
|
||||
tags: ['invoicing', 'vat', 'compliance', 'eu', 'rot-rut'],
|
||||
body,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Month-End Close — gnubok
|
||||
|
||||
Run this at the end of each calendar month to ensure books are clean before locking the period.
|
||||
|
||||
## When to use
|
||||
|
||||
Trigger this workflow when the user says any of:
|
||||
|
||||
- "Close out [month]"
|
||||
- "Stäng [månad]"
|
||||
- "Month-end close"
|
||||
- "Lock [period]"
|
||||
|
||||
Run it on the **last business day** of the month (or first business day of the next month). Locking too early prevents legitimate late entries; locking too late risks period-skew on VAT filings.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Book every business transaction
|
||||
|
||||
Goal: zero uncategorized business transactions inside the period.
|
||||
|
||||
1. Call \`gnubok_list_uncategorized_transactions\` to see what's outstanding.
|
||||
2. For each, call \`gnubok_suggest_categories\` (batches of up to 20) to get high-confidence proposals.
|
||||
3. Stage categorizations via \`gnubok_categorize_transaction\` (or, for income that matches an invoice, \`gnubok_match_transaction_to_invoice\`).
|
||||
4. The user approves each in the web app — staging is non-negotiable for legal compliance (BFL 5 kap.).
|
||||
|
||||
If a transaction is genuinely private, mark it as \`category: 'private'\` — no journal entry will be created.
|
||||
|
||||
### Step 2 — Reconcile bank
|
||||
|
||||
Run \`gnubok_get_reconciliation_status\` for the month's date range. The result includes \`bank_balance\`, \`ledger_balance\`, and \`difference\`. Any non-zero difference means unmatched transactions or missing JEs — investigate before locking.
|
||||
|
||||
### Step 3 — Check voucher gaps
|
||||
|
||||
Run \`gnubok_list_voucher_gaps\` for the fiscal period. **Every gap must have an explanation** per BFNAR 2013:2. Use \`gnubok_explain_voucher_gap\` to document each one (e.g., "Voucher number reserved but not used because invoice was cancelled before posting").
|
||||
|
||||
### Step 4 — Run VAT report (monthly filers)
|
||||
|
||||
If the company files VAT monthly (beskattningsunderlag > 40M SEK, or voluntarily), run \`gnubok_get_vat_report\` with \`period_type: 'monthly'\`. Sanity-check ruta49 ("att betala/återfå"). Use \`gnubok_vat_review_widget\` for a visual review.
|
||||
|
||||
If quarterly or annual filer: skip — VAT happens on its own cadence (see the quarterly-vat-review skill).
|
||||
|
||||
### Step 5 — Lock the period
|
||||
|
||||
Stage the lock via \`gnubok_lock_period(fiscal_period_id)\`. The tool refuses if any business transactions remain unbooked. After user approval, no new entries can be posted into the period — late corrections must use \`gnubok_unlock_period\` (also high-risk, also staged).
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Never delete journal entries.** Use \`gnubok_uncategorize_transaction\` (storno reversal) to undo. DB triggers enforce this — direct deletes will fail.
|
||||
- **Posted entries are immutable.** Once a JE is posted, even amounts are locked. Use \`correctEntry\` (web app) for corrections.
|
||||
- **Money math:** \`Math.round(x * 100) / 100\`, never \`toFixed()\`. The categorize tool handles this; if you compute manually, follow the same pattern.
|
||||
- **Locking ≠ closing.** Locking blocks new entries; closing (after year-end) is irreversible. This skill stops at locking.
|
||||
|
||||
## Common errors
|
||||
|
||||
- **"Period must be locked before closing"** — \`gnubok_close_period\` requires \`gnubok_lock_period\` first AND the year-end closing entry. Don't try to close mid-year periods.
|
||||
- **"Cannot lock period: N business transactions unbooked"** — Step 1 wasn't complete. Re-run \`gnubok_list_uncategorized_transactions\`.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_list_uncategorized_transactions\` — find unbooked transactions
|
||||
- \`gnubok_suggest_categories\` — get categorization proposals (batch of 20)
|
||||
- \`gnubok_categorize_transaction\` — stage a single categorization
|
||||
- \`gnubok_match_transaction_to_invoice\` — apply income to a customer invoice
|
||||
- \`gnubok_get_reconciliation_status\` — bank vs ledger balance
|
||||
- \`gnubok_list_voucher_gaps\` — BFNAR 2013:2 audit check
|
||||
- \`gnubok_explain_voucher_gap\` — document a gap
|
||||
- \`gnubok_get_vat_report\` — momsdeklaration data
|
||||
- \`gnubok_vat_review_widget\` — interactive VAT review
|
||||
- \`gnubok_lock_period\` — stage period lock
|
||||
- \`gnubok_uncategorize_transaction\` — undo a categorization (storno)
|
||||
`
|
||||
|
||||
export const monthEndCloseSkill: Skill = {
|
||||
slug: 'month-end-close',
|
||||
name: 'Month-End Close',
|
||||
summary: 'End-of-month workflow: book transactions, reconcile bank, verify voucher gaps, file VAT (monthly filers), lock period.',
|
||||
tags: ['monthly', 'close', 'reconciliation', 'vat'],
|
||||
body,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Monthly Payroll — gnubok
|
||||
|
||||
Salary run + AGI filing for one calendar month.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Run payroll for [month]"
|
||||
- "Lönekörning [månad]"
|
||||
- "Generate AGI"
|
||||
- Once per month, **before payment_date**
|
||||
|
||||
## Statutory deadlines
|
||||
|
||||
- **AGI (arbetsgivardeklaration):** 12th of the **next month** (17th in January and August). E.g. payroll for March → file AGI by 12 April.
|
||||
- **Skatt + sociala avgifter payment:** same deadline as AGI.
|
||||
- Skattekontot must be in funds by deadline (SFL 62 kap. 3 §).
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Verify employees are set up
|
||||
|
||||
\`gnubok_list_employees\` returns all active employees. For each, the system needs:
|
||||
|
||||
- **Personnummer** (last 4 stored, full encrypted)
|
||||
- **monthly_salary** (or hourly_rate + estimated hours)
|
||||
- **employment_degree** (1–100 %)
|
||||
- **tax_table_number** + **tax_column** (skattetabell + kolumn from Skatteverket)
|
||||
- **employment_type** (\`tjänsteman\`, \`arbetare\`, etc.) — drives BAS account choice (7210 vs 7010)
|
||||
|
||||
If anything is missing, the user fixes it in the web UI before running payroll.
|
||||
|
||||
### Step 2 — Create the salary run
|
||||
|
||||
\`gnubok_create_salary_run({ period_year, period_month, payment_date })\`
|
||||
|
||||
- Creates a \`salary_runs\` row with status \`draft\`
|
||||
- Adds **all active employees** with their base salary line (item_type \`monthly_salary\` or \`hourly_salary\`)
|
||||
- Returns the run ID + employee count
|
||||
- Idempotent on \`(company_id, period_year, period_month)\` — re-calling errors with "Salary run already exists for this period"
|
||||
|
||||
### Step 3 — Add OB-tillägg, traktamente, förmåner (if any)
|
||||
|
||||
Variable lines (overtime, weekend supplement, milage, traktamente, förmåner) are added in the web UI per-employee. There's no MCP tool yet for these — guide the user there.
|
||||
|
||||
### Step 4 — Calculate
|
||||
|
||||
\`gnubok_calculate_salary_run({ salary_run_id })\`. Computes per employee:
|
||||
|
||||
- **Bruttolön** (gross): sum of taxable salary lines
|
||||
- **Skatteavdrag**: tax-table lookup (skattetabell + kolumn → table column for the gross level)
|
||||
- **Nettolön** (net): bruttolön − skatteavdrag
|
||||
- **Sociala avgifter (arbetsgivaravgifter)**: 31.42 % of bruttolön (standard 2025). Reduced rates apply to specific age groups — always check the current statutory rates before relying on these:
|
||||
- **Born 1937 or earlier**: **0 %** — no avgifter at all (oldest cohort, never paid into the modern pension system). Easy to miss; the BAS journal entries for 7510/2730 simply don't apply for these employees.
|
||||
- **Age 66+ on 1 January of the income year (67+ from income year 2026)**: 10.21 % (only ålderspensionsavgift). The threshold rises with the riktålder; verify the cohort year for the current run rather than hard-coding a birth year.
|
||||
- **växa-stöd / temporary youth reduction**: ages 19–23, salary ≤ 25 000 SEK/month, capped duration. The exact rate and window vary year-over-year (e.g. 20.81 % during 1 Apr 2026 – 30 Sep 2027 per Prop. 2025/26:34) — confirm against Skatteverket's current published table before applying.
|
||||
- **Semesterlöneskuld** (vacation accrual): 12 % of bruttolön (default). Booked monthly to 2920.
|
||||
- **Förmåner** (benefits): employer-paid taxable amounts (bilförmån, kostförmån, etc.) — added to skattegrundande lön but not to nettolön payment.
|
||||
|
||||
Errors at this stage usually mean missing tax-table data — fall back to \`getDefaultTaxColumn(personnummer, year)\` heuristics or prompt user.
|
||||
|
||||
### Step 5 — Review
|
||||
|
||||
\`gnubok_get_salary_run({ salary_run_id })\` — full breakdown including \`calculation_breakdown\` showing step-by-step formulas. The user reviews per-employee in web UI.
|
||||
|
||||
\`gnubok_get_salary_journal({ year })\` — annual rollup for sanity check.
|
||||
|
||||
### Step 6 — Approve & book (web UI)
|
||||
|
||||
The user marks the run \`approved\` → \`paid\` → \`booked\` in the web UI. Booking creates the JE:
|
||||
|
||||
- Debit **7210** (lön tjänstemän) or **7010** (lön arbetare): bruttolön
|
||||
- Debit **7510** (sociala avgifter): \`avgift_base × applicable_rate\` — **per employee**, using the rate from Step 4 (default 31.42 %, or a reduced rate when applicable: 10.21 % for 66+, växa-stöd, etc.)
|
||||
- Credit **2710** (källskatt): skatteavdrag
|
||||
- Credit **2730** (lagstadgade arbetsgivaravgifter): same amount as the 7510 debit (the avgift cost is the same number as the avgift liability)
|
||||
- Credit **2920** (semesterlöneskuld): 12 % × bruttolön (debit 7290 to balance)
|
||||
- Credit **1930** (bank): nettolön (when paid)
|
||||
|
||||
When a run mixes full-rate and reduced-rate employees, the 7510/2730 lines are summed across all employees — the *total* avgift line equals \`Σ(per-employee avgift_base × per-employee rate)\`, **not** \`Σ bruttolön × 31.42 %\`. \`gnubok_calculate_salary_run\` already does this aggregation.
|
||||
|
||||
### Step 7 — Generate AGI
|
||||
|
||||
\`gnubok_generate_agi({ salary_run_id })\`. Run must be in \`review\`/\`approved\`/\`paid\`/\`booked\` status (past draft).
|
||||
|
||||
Returns \`{ message, period, employee_count, download_url }\`. The XML conforms to Skatteverket's AGI format and is stored 7 years per BFL. Download from \`/api/salary/runs/{id}/agi/xml\` and upload to Skatteverket e-tjänst.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Skatteavdrag is mandatory.** Never pay gross. Skatteverket charges 100% penalty for missing avdrag.
|
||||
- **Sociala avgifter are 31.42 % even if salary is in EUR.** Convert to SEK at payment date for the avgift base.
|
||||
- **Semesterlöneskuld** must be reserved monthly, not at year-end. 2920 grows by 12 % of every month's bruttolön.
|
||||
- **Förmånsbeskattning** (benefit tax) is required even if not in cash. Bilförmån, kostförmån, sjukvårdsförsäkring all count.
|
||||
- **Karensavdrag** (sick day deduction): first day of sickness is generally without pay; 80 % from day 2. Specific rules — fall through to \`swedish-payroll\` reference if unsure.
|
||||
|
||||
## Common errors
|
||||
|
||||
- **Run already exists**: idempotency on (company, year, month). Find the existing run with \`gnubok_get_salary_run\`.
|
||||
- **Tax table column wrong**: defaults to column 1 if not set, which is too high for most employees. Fix on employee record.
|
||||
- **AGI before booking**: works (status check is past-draft, not booked) — but you should book first so the JE matches what AGI reports.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_list_employees\` — verify setup
|
||||
- \`gnubok_create_salary_run\` — stage new monthly run
|
||||
- \`gnubok_calculate_salary_run\` — compute tax + avgifter + accrual
|
||||
- \`gnubok_get_salary_run\` — review breakdown
|
||||
- \`gnubok_get_salary_journal\` — annual rollup
|
||||
- \`gnubok_generate_agi\` — produce AGI XML for filing
|
||||
`
|
||||
|
||||
export const payrollMonthlySkill: Skill = {
|
||||
slug: 'payroll-monthly',
|
||||
name: 'Monthly Payroll',
|
||||
summary: 'Monthly salary run + AGI: employee setup, calculation, sociala avgifter, semesterlöneskuld, booking, AGI XML.',
|
||||
tags: ['monthly', 'payroll', 'agi', 'compliance'],
|
||||
body,
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Quarterly VAT Review — gnubok
|
||||
|
||||
End-to-end review of momsdeklaration (SKV 4700) before filing to Skatteverket.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Run VAT for Q[N]" / "Moms för kvartal [N]"
|
||||
- "How much VAT do I owe this quarter?"
|
||||
- After all transactions in the quarter are booked
|
||||
- Before the filing deadline (12th of the second month after quarter-end; 17 August for Q2)
|
||||
|
||||
## Filing deadlines
|
||||
|
||||
| Quarter | Deadline |
|
||||
|---------|----------|
|
||||
| Q1 (Jan–Mar) | **12 May** |
|
||||
| Q2 (Apr–Jun) | **17 August** (vacation rule) |
|
||||
| Q3 (Jul–Sep) | **12 November** |
|
||||
| Q4 (Oct–Dec) | **12 February** (next year) |
|
||||
|
||||
Weekend/holiday → next business day. Payment must reach Skattekontot by deadline (SFL 62 kap. 3 §).
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Verify the quarter is fully booked
|
||||
|
||||
Run the month-end-close skill for each month in the quarter. Critically: zero uncategorized business transactions in the date range, and bank reconciliation difference = 0.
|
||||
|
||||
### Step 2 — Generate the report
|
||||
|
||||
\`gnubok_get_vat_report({ period_type: 'quarterly', year: YYYY, period: 1|2|3|4 })\`
|
||||
|
||||
Returns all rutor (boxes) plus a summary string.
|
||||
|
||||
### Step 3 — Visual review
|
||||
|
||||
\`gnubok_vat_review_widget(...)\` opens a tabular UI. The user reviews each ruta inline, copies the summary, and confirms before filing.
|
||||
|
||||
### Step 4 — Drill into anomalies
|
||||
|
||||
If any ruta looks wrong, call \`gnubok_get_general_ledger\` filtered to the relevant 26xx account:
|
||||
|
||||
- Ruta 10 looks too high? → general ledger for **2611** (output 25%)
|
||||
- Ruta 48 looks too low? → general ledger for **2641** + **2645** (input + EU calculated)
|
||||
- Ruta 30 unexpectedly nonzero? → \`2614\` reverse-charge — verify the underlying purchase
|
||||
|
||||
### Step 5 — File and record payment
|
||||
|
||||
File via Skatteverket e-tjänst (or skatteverket extension if enabled). After filing, record the payment journal entry (debit/credit 2650/1930) when the money moves from skattekontot.
|
||||
|
||||
## Ruta-by-ruta map (what each box means)
|
||||
|
||||
| Ruta | Description | Source accounts |
|
||||
|------|-------------|-----------------|
|
||||
| 05 | Momspliktig försäljning (taxable sales, all rates) | 3001–3008, 3041–3048, 3051–3058, 3071–3078 (common BAS taxable revenue accounts) |
|
||||
| 10 | Utgående moms 25 % | 2611 |
|
||||
| 11 | Utgående moms 12 % | 2621 |
|
||||
| 12 | Utgående moms 6 % | 2631 |
|
||||
| 30 | Utgående moms reverse charge 25 % | 2614 |
|
||||
| 31 | Utgående moms reverse charge 12 % | 2624 |
|
||||
| 32 | Utgående moms reverse charge 6 % | 2634 |
|
||||
| 35 | EU-varuförsäljning, momsfri (intra-community goods supply) | 3108 |
|
||||
| 39 | EU-tjänsteförsäljning (services to EU B2B) | 3308 |
|
||||
| 40 | Export (outside EU) | 3305 |
|
||||
| 48 | Ingående moms (all input VAT) | 2641 + 2645 + 2647 |
|
||||
| 49 | **Att betala / återfå** | computed |
|
||||
|
||||
**Ruta 49 = (10 + 11 + 12 + 30 + 31 + 32) − 48.** Positive = pay. Negative = refund.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Reverse charge: never net silently.** Both the output (2614/2624/2634) and the calculated input (2645) MUST be booked separately. Skatteverket reads both rutor.
|
||||
- **Representation moms is capped at 300 SEK ex moms per person per occasion** (since 2017). Above the cap, no VAT deduction.
|
||||
- **Mixed verksamhet:** if the company has both VAT-liable and VAT-exempt revenue, input VAT requires proportional deduction (HFD 2023 ref. 45). Don't deduct full 2641 in that case.
|
||||
- **Bokslutsmetoden (cash) cap = 3 M SEK omsättning.** Above that, faktureringsmetoden (accrual) is required by law.
|
||||
|
||||
## Common errors
|
||||
|
||||
- **Forgetting Ruta 39 for EU services.** A Swedish consultant invoicing a German customer at 0% VAT (reverse charge) MUST report the invoice in ruta 39, not just ruta 05. Wrong ruta = penalty risk.
|
||||
- **Wrong rate on books/transport.** 6% applies (not 12%): books, newspapers, transport, sports admission, repairs. Restaurant food = 12%, drops to 6% from 1 April 2026.
|
||||
- **Filing late by one day.** Skattetillägg + interest. The system clock matters more than the user thinks.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_get_vat_report\` — generate momsdeklaration data
|
||||
- \`gnubok_vat_review_widget\` — interactive review widget
|
||||
- \`gnubok_get_general_ledger\` — drill into 26xx accounts
|
||||
- \`gnubok_list_uncategorized_transactions\` — verify nothing missing
|
||||
- \`gnubok_get_reconciliation_status\` — bank vs ledger sanity check
|
||||
`
|
||||
|
||||
export const quarterlyVatReviewSkill: Skill = {
|
||||
slug: 'quarterly-vat-review',
|
||||
name: 'Quarterly VAT Review',
|
||||
summary: 'End-to-end momsdeklaration: deadlines, ruta-by-ruta map, reverse charge rules, common errors, drill-down via general ledger.',
|
||||
tags: ['vat', 'quarterly', 'monthly', 'compliance', 'skatteverket'],
|
||||
body,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Skills over MCP — domain-knowledge bodies the server ships alongside tools.
|
||||
*
|
||||
* A skill is a versioned Markdown document that documents *how* to compose
|
||||
* gnubok tools to accomplish a real-world workflow (month-end close, VAT
|
||||
* review, year-end, invoicing, payroll). Agents call gnubok_load_skill(slug)
|
||||
* to load only the skills they need for the current task — keeping context
|
||||
* lean while shipping deep domain knowledge alongside the protocol.
|
||||
*
|
||||
* Forward-compatible: when MCP adds a native `skills/list` primitive, the
|
||||
* Skill interface and bodies migrate without changes.
|
||||
*/
|
||||
export interface Skill {
|
||||
/** URL-safe id, used in tool args and resource URIs (e.g. "month-end-close"). */
|
||||
slug: string
|
||||
/** Display name (e.g. "Month-End Close"). */
|
||||
name: string
|
||||
/** One-line summary used by gnubok_list_skills. */
|
||||
summary: string
|
||||
/** Tags for filtering (e.g. ['monthly', 'vat', 'reconciliation']). */
|
||||
tags: string[]
|
||||
/** Full skill body as Markdown. */
|
||||
body: string
|
||||
}
|
||||
|
||||
export const SKILL_MIME_TYPE = 'text/markdown' as const
|
||||
|
||||
/** Resource URI prefix for skills exposed via resources/read. */
|
||||
export const SKILL_URI_PREFIX = 'gnubok://skill/' as const
|
||||
|
||||
export function skillUri(slug: string): string {
|
||||
return `${SKILL_URI_PREFIX}${slug}`
|
||||
}
|
||||
|
||||
export function skillSlugFromUri(uri: string): string | null {
|
||||
if (!uri.startsWith(SKILL_URI_PREFIX)) return null
|
||||
return uri.slice(SKILL_URI_PREFIX.length) || null
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Skill } from './types'
|
||||
|
||||
const body = `# Year-End Close (Bokslut) — gnubok
|
||||
|
||||
The annual close. Irreversible. Legally significant. Always staged for human approval.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Run year-end" / "Bokslut för [år]"
|
||||
- "Close FY[year]"
|
||||
- After all monthly closes are done and the last period is locked
|
||||
- Before årsredovisning filing to Bolagsverket (AB) or NE-bilaga (enskild firma)
|
||||
|
||||
**Do not run year-end during the year.** It zeros result accounts (3xxx–8xxx) into 2099 (årets resultat) — only correct at the end of the räkenskapsår.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1 — Bokslutstransaktioner (accrual entries)
|
||||
|
||||
Before running year-end, post any year-end adjusting entries via the web app:
|
||||
|
||||
- **Förutbetalda kostnader / upplupna intäkter** (1700/1800-series accruals)
|
||||
- **Avskrivningar** (depreciation): planenlig + räkenskapsenlig 30 % / 20 % rule, or restvärde 25 %
|
||||
- **Periodiseringsfond** (AB only, max 25 % of överskott av näringsverksamhet **before** this year's avsättning per IL 30 kap.; 6-year mandatory reversal, oldest fond reversed first)
|
||||
- **Överavskrivning** (2150/8850 — bokföringsmässig avskrivning beyond skattemässig)
|
||||
- **Lagervärdering** (lägsta värdets princip)
|
||||
- **Skuld till företagaren / egenavgifter** (enskild firma)
|
||||
|
||||
These are not staged via MCP today — direct in web UI. The skill is to remind the user.
|
||||
|
||||
### Step 2 — Currency revaluation (if multi-currency)
|
||||
|
||||
If the company has open foreign-currency receivables/payables (1510/2440 in EUR/USD/etc.), revalue to closing-date FX rate via \`gnubok_run_currency_revaluation({ fiscal_period_id, closing_date })\`. Posts to **3960** (kursvinster) and **7960** (kursförluster). One revaluation per period.
|
||||
|
||||
### Step 3 — Lock the period
|
||||
|
||||
\`gnubok_lock_period(fiscal_period_id)\`. Required before year-end. Refuses if business transactions are unbooked.
|
||||
|
||||
### Step 4 — Run year-end
|
||||
|
||||
\`gnubok_run_year_end(fiscal_period_id)\` — stages a high-risk operation. After approval:
|
||||
|
||||
- Class 3–8 (revenue + expenses) zeroed into **2099** (årets resultat)
|
||||
- Period flagged \`is_year_end_complete\`
|
||||
- Next period created automatically
|
||||
|
||||
### Step 5 — Set opening balances
|
||||
|
||||
\`gnubok_set_opening_balances({ closed_period_id, next_period_id })\`. Copies class 1–2 closing balances into the next period as opening balances. Stage → approve.
|
||||
|
||||
### Step 6 — Close (final, irreversible)
|
||||
|
||||
\`gnubok_close_period(fiscal_period_id)\`. Once approved, the period is sealed forever. **No more entries possible — not even via storno.**
|
||||
|
||||
## Tax provisions to compute (AB)
|
||||
|
||||
After year-end JE but before filing INK2:
|
||||
|
||||
- **Bolagsskatt 20.6 %** of skattemässigt resultat (since 2021). Posted to 8910 → 2510.
|
||||
- **Periodiseringsfond:** max 25 % of överskott **before this year's avsättning** (IL 30 kap.). 6-year mandatory reversal; oldest fond reversed first to avoid statutory return.
|
||||
- **Räkenskapsenlig avskrivning:** must be applied consistently — switching method requires Skatteverket approval.
|
||||
|
||||
## Tax provisions (Enskild firma)
|
||||
|
||||
- **Egenavgifter** (28.97 % normal, 10.21 % age 66+) — reserves for next year's tax.
|
||||
- **Räntefördelning** (positive at 7.94 % on capital underlag 2025; 50 000 SEK floor).
|
||||
- **Expansionsfond** (max equity capital × 1.4; reversed when withdrawn).
|
||||
|
||||
These compute with \`gnubok_get_kpi_report\` for inputs but the actual tax JE is web-UI today.
|
||||
|
||||
## Critical rules
|
||||
|
||||
- **Year-end is forever.** Once \`gnubok_close_period\` succeeds, there is no rollback. \`gnubok_unlock_period\` cannot unlock a closed period — only one that is locked but not closed.
|
||||
- **Run order matters.** lock → year-end → opening balances → close. Any other order fails.
|
||||
- **K2 vs K3:** affects många bokslutsposter — start-up costs, leasing, immateriella tillgångar. The skill assumes K2 unless told otherwise.
|
||||
- **Revisionsplikt:** AB with > 3 M SEK omsättning, > 1.5 M SEK BR-omslutning, > 3 employees (any 2 of 3, two consecutive years) need auditor — book the audit before close.
|
||||
|
||||
## Common errors
|
||||
|
||||
- **"Period must be locked before closing"** — Step 3 missed.
|
||||
- **"Year-end closing entry must exist"** — Step 4 missed.
|
||||
- **Forgetting periodiseringsfond reversal** — must reverse the oldest 6-year-old fond automatically. Skatteverket WILL catch this.
|
||||
- **Skipping currency revaluation on FX exposure** — distorts BR; auditors flag.
|
||||
|
||||
## Tools
|
||||
|
||||
- \`gnubok_lock_period\` — pre-flight before year-end
|
||||
- \`gnubok_run_year_end\` — zero result accounts
|
||||
- \`gnubok_set_opening_balances\` — seed next period
|
||||
- \`gnubok_run_currency_revaluation\` — FX revaluation
|
||||
- \`gnubok_close_period\` — final, irreversible
|
||||
- \`gnubok_get_balance_sheet\` — verify post-year-end balances
|
||||
- \`gnubok_get_income_statement\` — verify result before year-end JE
|
||||
- \`gnubok_get_trial_balance\` — sanity check before each step
|
||||
`
|
||||
|
||||
export const yearEndCloseSkill: Skill = {
|
||||
slug: 'year-end-close',
|
||||
name: 'Year-End Close (Bokslut)',
|
||||
summary: 'Annual close: bokslutstransaktioner, currency revaluation, lock → year-end → opening balances → close. Irreversible.',
|
||||
tags: ['yearly', 'close', 'bokslut', 'compliance'],
|
||||
body,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { UiWidget } from './types'
|
||||
import { receiptMatcherWidget } from './receipt-matcher'
|
||||
import { vatReviewWidget } from './vat-review'
|
||||
|
||||
export const uiWidgets: UiWidget[] = [
|
||||
receiptMatcherWidget,
|
||||
vatReviewWidget,
|
||||
]
|
||||
|
||||
export function findUiWidget(uri: string): UiWidget | null {
|
||||
return uiWidgets.find((w) => w.uri === uri) ?? null
|
||||
}
|
||||
|
||||
export type { UiWidget } from './types'
|
||||
export { WIDGET_MIME_TYPE } from './types'
|
||||
+13
-6
@@ -1,9 +1,9 @@
|
||||
import type { UiWidget } from './types'
|
||||
|
||||
/**
|
||||
* Receipt Matcher Widget — MCP Apps inline HTML
|
||||
*
|
||||
* Self-contained HTML document rendered in an iframe by MCP Apps hosts
|
||||
* (Claude Desktop, etc.). Communicates exclusively via postMessage
|
||||
* (JSON-RPC 2.0 over the MCP Apps protocol). No fetch() calls.
|
||||
* Receipt Matcher Widget — MCP Apps inline HTML.
|
||||
* Drag-and-drop receipt attachment for uncategorized bank transactions.
|
||||
* Triggered by the gnubok_receipt_matcher tool.
|
||||
*/
|
||||
|
||||
export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
|
||||
@@ -377,4 +377,11 @@ export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
</html>`
|
||||
|
||||
export const receiptMatcherWidget: UiWidget = {
|
||||
uri: 'ui://receipt-matcher/app.html',
|
||||
name: 'Receipt Matcher',
|
||||
description: 'Interactive widget for matching receipts to uncategorized transactions',
|
||||
html: RECEIPT_MATCHER_HTML,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* MCP Apps inline widget contract.
|
||||
*
|
||||
* Each widget is a self-contained HTML document rendered in an iframe by
|
||||
* MCP Apps hosts (Claude Desktop, Claude Web). Widgets communicate with
|
||||
* the host exclusively via postMessage / JSON-RPC 2.0; never via fetch().
|
||||
*/
|
||||
export interface UiWidget {
|
||||
/** Resource URI clients use to load the widget — e.g. `ui://vat-review/app.html`. */
|
||||
uri: string
|
||||
/** Display name shown in the host's resource list. */
|
||||
name: string
|
||||
/** One-line description shown alongside the resource. */
|
||||
description: string
|
||||
/** Self-contained HTML document (no external network access). */
|
||||
html: string
|
||||
}
|
||||
|
||||
export const WIDGET_MIME_TYPE = 'text/html;profile=mcp-app' as const
|
||||
@@ -0,0 +1,397 @@
|
||||
import type { UiWidget } from './types'
|
||||
|
||||
/**
|
||||
* VAT Review Widget — MCP Apps inline HTML.
|
||||
* Read-only review of momsdeklaration (SKV 4700) before filing to Skatteverket.
|
||||
* Triggered by the gnubok_vat_review_widget tool.
|
||||
*/
|
||||
|
||||
export const VAT_REVIEW_HTML = `<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Momsdeklaration — gnubok</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafafa;
|
||||
--surface: #ffffff;
|
||||
--border: rgba(0,0,0,0.1);
|
||||
--border-strong: rgba(0,0,0,0.18);
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #6b6b6b;
|
||||
--text-faint: #999;
|
||||
--success: #5a7a5a;
|
||||
--success-bg: rgba(90,122,90,0.08);
|
||||
--error: #b35a3a;
|
||||
--error-bg: rgba(179,90,58,0.08);
|
||||
--accent: #3b3b3b;
|
||||
--code: #f0f0f0;
|
||||
}
|
||||
.dark {
|
||||
--bg: #161616;
|
||||
--surface: #1e1e1e;
|
||||
--border: rgba(255,255,255,0.1);
|
||||
--border-strong: rgba(255,255,255,0.18);
|
||||
--text: #e5e5e5;
|
||||
--text-muted: #999;
|
||||
--text-faint: #777;
|
||||
--success: #7aab7a;
|
||||
--success-bg: rgba(122,171,122,0.1);
|
||||
--error: #d4816a;
|
||||
--error-bg: rgba(212,129,106,0.1);
|
||||
--accent: #ccc;
|
||||
--code: #2a2a2a;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding: 12px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header h1 { font-size: 15px; font-weight: 600; }
|
||||
.period { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.loading { text-align: center; padding: 32px; color: var(--text-muted); }
|
||||
|
||||
.summary {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.summary.pay { border-left: 3px solid var(--error); }
|
||||
.summary.refund { border-left: 3px solid var(--success); }
|
||||
.summary.zero { border-left: 3px solid var(--text-faint); }
|
||||
.summary-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.warnings {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
}
|
||||
.warnings ul { margin: 0; padding-left: 20px; }
|
||||
.warnings-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-amount {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.summary-amount.pay { color: var(--error); }
|
||||
.summary-amount.refund { color: var(--success); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
th.amount { text-align: right; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
td.ruta-code {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
width: 60px;
|
||||
}
|
||||
td.amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td.label { color: var(--text); }
|
||||
td.label .sub { display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
tr.section td {
|
||||
background: var(--bg);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
padding-top: 14px;
|
||||
padding-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
tr.total td {
|
||||
background: var(--bg);
|
||||
font-weight: 600;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover { background: var(--bg); }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--surface);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.primary:hover { opacity: 0.85; background: var(--accent); }
|
||||
.copied {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--success);
|
||||
align-self: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.copied.shown { opacity: 1; }
|
||||
|
||||
.empty { text-align: center; padding: 32px; color: var(--text-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Momsdeklaration</h1>
|
||||
<span class="period" id="period">—</span>
|
||||
</div>
|
||||
<div id="content"><div class="loading">Laddar…</div></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// ── MCP Apps Bridge ──
|
||||
let rpcId = 1
|
||||
const pending = new Map()
|
||||
let report = null
|
||||
|
||||
function sendRequest(method, params) {
|
||||
const id = rpcId++
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*')
|
||||
})
|
||||
}
|
||||
function sendNotification(method, params) {
|
||||
window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*')
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
const msg = e.data
|
||||
if (!msg || msg.jsonrpc !== '2.0') return
|
||||
|
||||
if (msg.id != null && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id)
|
||||
pending.delete(msg.id)
|
||||
if (msg.error) reject(msg.error)
|
||||
else resolve(msg.result)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.method === 'ui/notifications/tool-result') {
|
||||
const sc = msg.params && msg.params.structuredContent
|
||||
if (sc && sc.rutor) {
|
||||
report = sc
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg.method === 'ui/notifications/host-context-changed') {
|
||||
applyTheme(msg.params)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function applyTheme(ctx) {
|
||||
if (!ctx) return
|
||||
if (ctx.theme === 'dark') document.documentElement.classList.add('dark')
|
||||
else document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
sendRequest('ui/initialize', { name: 'gnubok-vat-review', version: '1.0.0' })
|
||||
.then(function(res) {
|
||||
if (res && res.hostContext) applyTheme(res.hostContext)
|
||||
sendNotification('ui/notifications/initialized')
|
||||
})
|
||||
.catch(function() { sendNotification('ui/notifications/initialized') })
|
||||
|
||||
// ── Render ──
|
||||
function fmt(n) {
|
||||
return Number(n || 0).toLocaleString('sv-SE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}) + ' kr'
|
||||
}
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML }
|
||||
|
||||
function render() {
|
||||
const el = document.getElementById('content')
|
||||
const periodEl = document.getElementById('period')
|
||||
if (!report || !report.rutor) {
|
||||
el.innerHTML = '<div class="empty">Ingen momsdata för perioden.</div>'
|
||||
return
|
||||
}
|
||||
|
||||
periodEl.textContent = report.period_label || ''
|
||||
const r = report.rutor
|
||||
const ruta49 = Number(r.ruta49 || 0)
|
||||
const summaryClass = ruta49 > 0 ? 'pay' : ruta49 < 0 ? 'refund' : 'zero'
|
||||
const summaryLabel = ruta49 > 0 ? 'Moms att betala' : ruta49 < 0 ? 'Moms att få tillbaka' : 'Noll i moms'
|
||||
const summaryAmt = fmt(Math.abs(ruta49))
|
||||
|
||||
let html = ''
|
||||
|
||||
// Pre-filing warnings (e.g. one-sided reverse charge) — surface before the summary.
|
||||
const warnings = Array.isArray(report.warnings) ? report.warnings : []
|
||||
if (warnings.length > 0) {
|
||||
html += '<div class="warnings">'
|
||||
html += '<div class="warnings-title">Att granska före inlämning</div>'
|
||||
html += '<ul>'
|
||||
for (const w of warnings) html += '<li>' + esc(String(w)) + '</li>'
|
||||
html += '</ul>'
|
||||
html += '</div>'
|
||||
}
|
||||
|
||||
html += '<div class="summary ' + summaryClass + '">'
|
||||
html += ' <div><div class="summary-label">' + esc(summaryLabel) + '</div></div>'
|
||||
html += ' <div class="summary-amount ' + summaryClass + '">' + esc(summaryAmt) + '</div>'
|
||||
html += '</div>'
|
||||
|
||||
html += '<table>'
|
||||
html += '<thead><tr><th>Ruta</th><th>Beskrivning</th><th class="amount">Belopp</th></tr></thead>'
|
||||
html += '<tbody>'
|
||||
|
||||
html += '<tr class="section"><td colspan="3">Försäljning</td></tr>'
|
||||
html += row('05', 'Momspliktig försäljning', 'all momspliktig försäljning oavsett skattesats', r.ruta05)
|
||||
html += row('35', 'EU-varuförsäljning (momsfri)', '3108', r.ruta35)
|
||||
html += row('39', 'Försäljning av tjänster (EU)', '3308', r.ruta39)
|
||||
html += row('40', 'Export', '3305', r.ruta40)
|
||||
|
||||
html += '<tr class="section"><td colspan="3">Utgående moms</td></tr>'
|
||||
html += row('10', 'Utgående moms 25 %', '2611', r.ruta10)
|
||||
html += row('11', 'Utgående moms 12 %', '2621', r.ruta11)
|
||||
html += row('12', 'Utgående moms 6 %', '2631', r.ruta12)
|
||||
|
||||
html += '<tr class="section"><td colspan="3">Utgående moms — omvänd betalningsskyldighet</td></tr>'
|
||||
html += row('30', 'Utgående moms 25 % (reverse charge)', '2614', r.ruta30)
|
||||
html += row('31', 'Utgående moms 12 % (reverse charge)', '2624', r.ruta31)
|
||||
html += row('32', 'Utgående moms 6 % (reverse charge)', '2634', r.ruta32)
|
||||
|
||||
html += '<tr class="section"><td colspan="3">Ingående moms</td></tr>'
|
||||
html += row('48', 'Ingående moms', '2641 + 2645 + 2647', r.ruta48)
|
||||
|
||||
html += '<tr class="total"><td class="ruta-code">49</td><td class="label">Att betala / återfå</td><td class="amount">' + fmt(ruta49) + '</td></tr>'
|
||||
|
||||
html += '</tbody></table>'
|
||||
|
||||
html += '<div class="actions">'
|
||||
html += '<span class="copied" id="copied">Kopierat</span>'
|
||||
html += '<button id="copy-json">Kopiera JSON</button>'
|
||||
html += '<button id="copy-summary" class="primary">Kopiera sammanfattning</button>'
|
||||
html += '</div>'
|
||||
|
||||
el.innerHTML = html
|
||||
|
||||
document.getElementById('copy-json').addEventListener('click', function() {
|
||||
copyToClipboard(JSON.stringify(report, null, 2))
|
||||
})
|
||||
document.getElementById('copy-summary').addEventListener('click', function() {
|
||||
const lines = [
|
||||
'Momsdeklaration ' + (report.period_label || ''),
|
||||
'Ruta 05: ' + fmt(r.ruta05),
|
||||
'Ruta 10: ' + fmt(r.ruta10),
|
||||
'Ruta 11: ' + fmt(r.ruta11),
|
||||
'Ruta 12: ' + fmt(r.ruta12),
|
||||
'Ruta 30: ' + fmt(r.ruta30),
|
||||
'Ruta 31: ' + fmt(r.ruta31),
|
||||
'Ruta 32: ' + fmt(r.ruta32),
|
||||
'Ruta 35: ' + fmt(r.ruta35),
|
||||
'Ruta 39: ' + fmt(r.ruta39),
|
||||
'Ruta 40: ' + fmt(r.ruta40),
|
||||
'Ruta 48: ' + fmt(r.ruta48),
|
||||
'Ruta 49: ' + fmt(r.ruta49) + ' (' + summaryLabel + ')',
|
||||
]
|
||||
copyToClipboard(lines.join('\\n'))
|
||||
})
|
||||
}
|
||||
|
||||
function row(code, label, sub, value) {
|
||||
return '<tr>'
|
||||
+ '<td class="ruta-code">' + esc(code) + '</td>'
|
||||
+ '<td class="label">' + esc(label) + '<span class="sub">' + esc(sub) + '</span></td>'
|
||||
+ '<td class="amount">' + fmt(value) + '</td>'
|
||||
+ '</tr>'
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(showCopied, showCopied)
|
||||
} else {
|
||||
// Fallback for older sandboxes
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try { document.execCommand('copy') } catch (_) {}
|
||||
document.body.removeChild(ta)
|
||||
showCopied()
|
||||
}
|
||||
}
|
||||
function showCopied() {
|
||||
const el = document.getElementById('copied')
|
||||
if (!el) return
|
||||
el.classList.add('shown')
|
||||
setTimeout(function() { el.classList.remove('shown') }, 1400)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export const vatReviewWidget: UiWidget = {
|
||||
uri: 'ui://vat-review/app.html',
|
||||
name: 'VAT Review',
|
||||
description: 'Interactive review of momsdeklaration (SKV 4700) before filing',
|
||||
html: VAT_REVIEW_HTML,
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const SCOPE_GROUPS = [
|
||||
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
|
||||
] as const
|
||||
|
||||
/** Map MCP tool name → required scope */
|
||||
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
|
||||
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Transactions
|
||||
gnubok_list_uncategorized_transactions: 'transactions:read',
|
||||
@@ -69,6 +69,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Reports
|
||||
gnubok_get_trial_balance: 'reports:read',
|
||||
gnubok_get_vat_report: 'reports:read',
|
||||
gnubok_vat_review_widget: 'reports:read',
|
||||
gnubok_get_kpi_report: 'reports:read',
|
||||
gnubok_get_income_statement: 'reports:read',
|
||||
gnubok_list_accounts: 'reports:read',
|
||||
|
||||
@@ -33,6 +33,11 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
|
||||
'invoice.match_confirmed',
|
||||
'supplier_invoice.match_confirmed',
|
||||
'supplier_invoice.confirmed',
|
||||
// MCP telemetry — every tool invocation, tools/list call, and resources/read.
|
||||
// Lightweight metadata only; 30-day TTL on event_log bounds the volume.
|
||||
'mcp.tool_called',
|
||||
'mcp.tools_list_called',
|
||||
'mcp.resource_read',
|
||||
]
|
||||
|
||||
// Excluded (with reasoning):
|
||||
|
||||
@@ -90,6 +90,53 @@ export type CoreEvent =
|
||||
// Company & account lifecycle
|
||||
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
||||
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
||||
// MCP telemetry — fired from the MCP dispatcher.
|
||||
// Persisted to event_log (30-day TTL) for hot-tool / error-rate / latency analytics.
|
||||
// Intentionally lightweight: no args, no result body — only metadata.
|
||||
| { type: 'mcp.tool_called'; payload: {
|
||||
tool: string // e.g. 'gnubok_create_invoice'
|
||||
requiredScope: string | null // from TOOL_SCOPE_MAP, null if unscoped
|
||||
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
||||
actorId: string | null // api_key id, oauth client, etc.
|
||||
actorLabel: string | null // human-readable actor label
|
||||
latencyMs: number // wall-clock time inside execute()
|
||||
success: boolean // true iff the tool returned without throwing AND was invoked (not denied)
|
||||
isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client
|
||||
errorCode: string | null // structured error code from tool-result.toToolError when applicable
|
||||
errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null
|
||||
requestId: string | number | null // JSON-RPC request id (helps correlate with client-side logs)
|
||||
userId: string
|
||||
companyId: string
|
||||
}}
|
||||
// tools/list — informs us whether agents are using progressive discovery
|
||||
// (gnubok_search_tools) or pulling the full list. Tool counts vary with
|
||||
// the caller's scope set.
|
||||
| { type: 'mcp.tools_list_called'; payload: {
|
||||
toolCount: number // tools actually returned (post scope filter)
|
||||
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
||||
actorId: string | null
|
||||
actorLabel: string | null
|
||||
latencyMs: number
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
companyId: string
|
||||
}}
|
||||
// resources/read — informs us which skills/widgets/data resources actually
|
||||
// get loaded by agents. `kind` discriminates by URI scheme so we can
|
||||
// GROUP BY skill vs widget vs data without parsing URIs.
|
||||
| { type: 'mcp.resource_read'; payload: {
|
||||
uri: string // e.g. 'gnubok://skill/month-end-close'
|
||||
kind: 'widget' | 'skill' | 'data' | 'unknown'
|
||||
success: boolean
|
||||
errorCode: string | null
|
||||
latencyMs: number
|
||||
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
||||
actorId: string | null
|
||||
actorLabel: string | null
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
companyId: string
|
||||
}}
|
||||
|
||||
// ============================================================
|
||||
// Helper Types
|
||||
|
||||
Reference in New Issue
Block a user