feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5) (#692)

* feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5)

Expose the complete Skatteverket extension as five MCP tools so VAT
(momsdeklaration) and employer (AGI/arbetsgivardeklaration) filing can be
driven from Claude. Commit = "send for BankID signing" (returns a signing
link), never "file" — the user's signature in the browser is the irreversible
act, kept outside the tooling.

Tools (extensions/general/mcp-server/server.ts):
- gnubok_vat_declaration_validate  (compliance:read) — live POST /kontrollera
- gnubok_vat_declaration_submit    (skatteverket:write) — stages submit_vat_declaration
- gnubok_vat_declaration_status    (compliance:read) — GET /inlamnat + /beslutat
- gnubok_agi_submit                (skatteverket:write) — stages submit_agi
- gnubok_agi_status                (compliance:read) — local state + live kvittenser

Architecture:
- Core (lib/pending-operations/commit.ts) cannot import @/extensions (CI guard),
  so the two submit ops dispatch into the extension via the new
  Extension.services channel (first use): registry-resolved
  commitSubmitVatDeclaration / commitSubmitAgi run the SKV chain and return a
  shared SkvSubmitResult (lib/pending-operations/skatteverket-commit.ts).
- Recoverable failures (extension disabled, no connection, rate-limited, still
  processing) release the op back to 'pending' via SkatteverketRecoverableError
  — same contract as AccountsNotInChartError — so the user reconnects and
  re-approves the SAME op. SKV business rejections reject the op.
- No-drift: parseDeclarationRequest / loadAGIXml extracted to
  lib/declaration-prep.ts (buildMomsuppgift / buildAgiUnderlag / resolveRedovisare)
  so route, preview, and commit file identical figures. writeSkatteverketAudit
  hoisted to lib/audit.ts; read tools + executors write BFL audit rows too.
- New scope skatteverket:write (opt-in, in STAGING_SCOPES so SoD ack fires),
  4 structured error codes, sv/en strings, ApiKeysPanel row.
- Migration 20260620120000 adds submit_vat_declaration / submit_agi to the
  pending_operations.operation_type CHECK (must apply to prod post-merge).

Tests: 42 new across executors, MCP tools, declaration-prep, error-map, and the
VAT commit chain. Full suite green (5287), build clean, lint-ratchet at baseline.

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

* ci: add PR-Agent AI review (SHA-pinned, dedicated Bedrock key)

Greptile went silent after #682 (app/account-side, not repo config). Add the
open-source PR-Agent GitHub Action as a replacement, hardened for supply chain:

- Pinned to the v0.36.0 commit SHA (ffe1f89), not the movable tag — the repo
  was recently transferred to a new, unverified org (The-PR-Agent), though it's
  the genuine original pr-agent (repo id 662766482, 11.5k stars).
- Runs on a DEDICATED, minimal IAM key (bedrock:InvokeModel only) via
  PR_AGENT_AWS_* secrets — never the app's general AWS credentials.
- Only /review runs automatically; /describe and /improve are disabled so PR
  descriptions are never overwritten.

Requires three new secrets before it functions: PR_AGENT_AWS_ACCESS_KEY_ID,
PR_AGENT_AWS_SECRET_ACCESS_KEY, PR_AGENT_AWS_REGION (EU region).

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

* ci(pr-agent): handle push events + restrict push to /review

PR-Agent skips synchronize (push) events by default, so the bot ran green but
posted nothing. Enable handle_push_trigger and scope push_commands to /review.

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

* ci(pr-agent): fix pr_actions (event list, not commands) + add synchronize

pr_actions is the list of PR event actions to handle, not slash-commands.
Setting it to ["/review"] removed every real event from the allowlist, so the
bot skipped everything. Restore the default events + synchronize; command
selection stays on the auto_review/describe/improve booleans (review-only).

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

* ci(pr-agent): raise max_model_tokens to 64k for fuller diff coverage

Default ~32k input window truncated large PRs. Sonnet 4.6 has 200k context.

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

* ci(pr-agent): use Claude Opus 4.8 (Sonnet 4.6 fallback)

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

* fix(skatteverket): scope AGI status flips by salary_run_id

Bot review (swedish-compliance) caught that commitSubmitAgi flipped
agi_declarations status by (company_id, period) only. A correction run sharing
the period would have its still-valid declaration co-flipped to rejected/
pending_signature. Scope both updates by salary_run_id (in scope from params) —
more precise than the period-only route handler, which has no run id.

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

* test(pg): fix gen_random_bytes assertion for modern pgcrypto

OpenSSL-backed pgcrypto (CI Postgres image) rejects gen_random_bytes(0) with
'Length not in range' rather than returning empty bytea, so the pre-existing
'returns empty bytea' assertion fails on every pg-real run (repo-wide, not
specific to this PR). Assert the real contract — exactly n bytes for a positive
n — instead of the version-dependent 0-byte edge case.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-08 12:43:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 122bcbbcc1
commit 679b154ad2
22 changed files with 1913 additions and 165 deletions
+79
View File
@@ -0,0 +1,79 @@
name: PR Agent
# AI pull-request review (PR-Agent, the original open-source reviewer — repo id
# 662766482, same repo the qodo-ai/Codium-ai names redirect to). Replaces the
# Greptile bot that went silent after #682.
#
# Supply-chain hardening:
# * Pinned to an immutable commit SHA (v0.36.0), NOT a movable tag, because the
# repo now sits under a recently-created, unverified org (The-PR-Agent).
# * Runs on a DEDICATED, minimal IAM key (bedrock:InvokeModel only) supplied via
# PR_AGENT_AWS_* secrets — never the app's general AWS credentials. A leaked
# PR-Agent key can do nothing but invoke the one Bedrock model.
#
# Scope: ONLY /review runs automatically. /describe and /improve are disabled so
# the bot never overwrites hand-written PR descriptions. Users can still invoke
# any command interactively by commenting e.g. "/describe" or "/improve" on a PR.
on:
pull_request:
types: [opened, reopened, ready_for_review, synchronize]
issue_comment:
types: [created, edited]
# One run per PR; cancel a superseded run when a new push lands.
concurrency:
group: pr-agent-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr_agent:
# Skip bot-authored events (vercel/supabase/etc.) to avoid feedback loops.
if: ${{ github.event.sender.type != 'Bot' }}
runs-on: ubuntu-latest
name: The PR Agent
steps:
- name: The PR Agent
# Pinned to the v0.36.0 commit SHA (immutable) — do not switch to @v0.36.0.
uses: The-PR-Agent/pr-agent@ffe1f89a4dafc7d8e88b9cf010a3233e30b49f43 # v0.36.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ── DEDICATED Bedrock IAM key (bedrock:InvokeModel only) — NOT the
# app's AWS_* secrets. litellm reads AWS_REGION_NAME; AWS_REGION is
# set too for safety. Create these three repo/org secrets:
# PR_AGENT_AWS_ACCESS_KEY_ID, PR_AGENT_AWS_SECRET_ACCESS_KEY,
# PR_AGENT_AWS_REGION (an EU region, e.g. eu-west-1).
AWS_ACCESS_KEY_ID: ${{ secrets.PR_AGENT_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.PR_AGENT_AWS_SECRET_ACCESS_KEY }}
AWS_REGION_NAME: ${{ secrets.PR_AGENT_AWS_REGION }}
AWS_REGION: ${{ secrets.PR_AGENT_AWS_REGION }}
# ── Model: Claude Opus 4.8 via the EU Bedrock inference profile, with
# Sonnet 4.6 as fallback if the Opus profile isn't enabled for these
# creds. custom_model_max_tokens is required because these ids are not
# in PR-Agent's built-in token map.
CONFIG.MODEL: "bedrock/eu.anthropic.claude-opus-4-8"
CONFIG.MODEL_WEAK: "bedrock/eu.anthropic.claude-sonnet-4-6"
CONFIG.FALLBACK_MODELS: '["bedrock/eu.anthropic.claude-sonnet-4-6"]'
CONFIG.CUSTOM_MODEL_MAX_TOKENS: "200000"
# Input window PR-Agent prunes the diff to fit. Default (~32k) truncated
# large PRs; raise it so the whole diff is reviewed (Sonnet 4.6 = 200k ctx).
CONFIG.MAX_MODEL_TOKENS: "64000"
LITELLM.DROP_PARAMS: "true"
# ── pr_actions = which GitHub PR *event actions* trigger the bot
# (NOT a command list). Default omits 'synchronize', so pushes are
# skipped; we add it so every push is reviewed too.
GITHUB_ACTION_CONFIG.PR_ACTIONS: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
# ── Which commands actually run on a handled event. Only review —
# describe/improve off so the bot never rewrites the PR body or
# pushes code suggestions.
GITHUB_ACTION_CONFIG.AUTO_REVIEW: "true"
GITHUB_ACTION_CONFIG.AUTO_DESCRIBE: "false"
GITHUB_ACTION_CONFIG.AUTO_IMPROVE: "false"
+7 -1
View File
@@ -129,9 +129,15 @@ const SCOPE_GROUPS: ScopeGroup[] = [
{
domain: 'compliance',
labelKey: 'group_compliance',
read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 0 },
read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 3 },
write: null,
},
{
domain: 'skatteverket',
labelKey: 'group_skatteverket',
read: null,
write: { scope: 'skatteverket:write', labelKey: 'scope_skatteverket_write', tools: 2 },
},
]
type Scope = ApiKeyScope
@@ -0,0 +1,197 @@
/**
* Safety tests for the Skatteverket MCP tools (PR5).
*
* Five tools wrap the skatteverket extension lib: two read tools hit SKV live
* (validate, status) and two submit tools stage high-risk ops whose commit
* dispatches into the extension (covered separately in
* lib/pending-operations/__tests__/skatteverket-executors.test.ts). The
* cross-extension lib modules are mocked so no real SKV call is made.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP, findStageApproveConflict } from '@/lib/auth/api-keys'
const mockSkvRequest = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, skvRequest: (...a: unknown[]) => mockSkvRequest(...a) }
})
const mockBuildMomsuppgift = vi.fn()
const mockResolveRedovisare = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/declaration-prep', () => ({
buildMomsuppgift: (...a: unknown[]) => mockBuildMomsuppgift(...a),
resolveRedovisare: (...a: unknown[]) => mockResolveRedovisare(...a),
}))
const mockKvittenser = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/agi-client', () => ({
agiGetKvittenser: (...a: unknown[]) => mockKvittenser(...a),
}))
// Audit writes are exercised in the extension; mock them out here so the test
// supabase queue only has to account for staging reads.
vi.mock('@/extensions/general/skatteverket/lib/audit', () => ({
writeSkatteverketAudit: vi.fn(),
}))
import { tools } from '../server'
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
const validate = tools.find((t) => t.name === 'gnubok_vat_declaration_validate')!
const vatSubmit = tools.find((t) => t.name === 'gnubok_vat_declaration_submit')!
const vatStatus = tools.find((t) => t.name === 'gnubok_vat_declaration_status')!
const agiSubmit = tools.find((t) => t.name === 'gnubok_agi_submit')!
const agiStatus = tools.find((t) => t.name === 'gnubok_agi_status')!
const ALL = [validate, vatSubmit, vatStatus, agiSubmit, agiStatus]
let prevEnv: string | undefined
beforeEach(() => {
vi.clearAllMocks()
prevEnv = process.env.SKATTEVERKET_ENABLED
process.env.SKATTEVERKET_ENABLED = 'true'
})
afterEach(() => {
if (prevEnv === undefined) delete process.env.SKATTEVERKET_ENABLED
else process.env.SKATTEVERKET_ENABLED = prevEnv
})
describe('Skatteverket tools — catalog', () => {
it('registers all five tools', () => {
expect(ALL.every(Boolean)).toBe(true)
})
it('has Title Case titles with the Swedish law term inline', () => {
expect(validate.title).toBe('Validate VAT Declaration (Momsdeklaration)')
expect(vatSubmit.title).toBe('Submit VAT Declaration (Momsdeklaration)')
expect(agiSubmit.title).toBe('Submit AGI Declaration (Arbetsgivardeklaration)')
})
it('all are openWorldHint (external system); reads are read-only, submits are not', () => {
for (const t of ALL) expect(t.annotations.openWorldHint).toBe(true)
expect(validate.annotations.readOnlyHint).toBe(true)
expect(vatStatus.annotations.readOnlyHint).toBe(true)
expect(agiStatus.annotations.readOnlyHint).toBe(true)
expect(vatSubmit.annotations.readOnlyHint).toBe(false)
expect(agiSubmit.annotations.readOnlyHint).toBe(false)
})
})
describe('Skatteverket tools — EXTENSION_DISABLED gate', () => {
it('every tool throws EXTENSION_DISABLED with the env off, making zero SKV calls', async () => {
delete process.env.SKATTEVERKET_ENABLED
const { supabase } = createQueuedMockSupabase()
const fetchSpy = vi.spyOn(globalThis, 'fetch')
for (const t of ALL) {
const args = t.name.includes('agi') ? { salary_run_id: 'sr-1' } : { period_type: 'monthly', year: 2025, period: 3 }
let thrown: unknown
try {
await t.execute(args, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
} catch (err) {
thrown = err
}
expect((thrown as Error & { code?: string })?.code, t.name).toBe('EXTENSION_DISABLED')
}
expect(mockSkvRequest).not.toHaveBeenCalled()
expect(mockKvittenser).not.toHaveBeenCalled()
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})
})
describe('gnubok_vat_declaration_validate', () => {
it('maps a SkatteverketAuthError(NOT_CONNECTED) to SKATTEVERKET_NOT_CONNECTED', async () => {
mockBuildMomsuppgift.mockResolvedValue({ redovisare: '165560000000', redovisningsperiod: '202503', momsuppgift: {} })
mockSkvRequest.mockRejectedValue(new SkatteverketAuthError('ingen anslutning', 'NOT_CONNECTED'))
const { supabase } = createQueuedMockSupabase()
let thrown: unknown
try {
await validate.execute({ period_type: 'monthly', year: 2025, period: 3 }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
} catch (err) {
thrown = err
}
expect((thrown as Error & { code?: string })?.code).toBe('SKATTEVERKET_NOT_CONNECTED')
})
it('happy path returns kontrollresultat', async () => {
mockBuildMomsuppgift.mockResolvedValue({ redovisare: '165560000000', redovisningsperiod: '202503', momsuppgift: { summaMoms: 100 } })
mockSkvRequest.mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 'OK', resultat: [] }) })
const { supabase } = createQueuedMockSupabase()
const result = (await validate.execute(
{ period_type: 'monthly', year: 2025, period: 3 }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { kontrollresultat: { status: string }; redovisningsperiod: string }
expect(result.kontrollresultat.status).toBe('OK')
expect(result.redovisningsperiod).toBe('202503')
// Only /kontrollera was called — nothing was saved at SKV.
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
})
})
describe('gnubok_vat_declaration_submit', () => {
it('validates via /kontrollera then stages — never touches /utkast', async () => {
mockBuildMomsuppgift.mockResolvedValue({ redovisare: '165560000000', redovisningsperiod: '202503', momsuppgift: { summaMoms: 100 } })
mockSkvRequest.mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 'OK' }) })
const { supabase, enqueue } = createQueuedMockSupabase()
// stagePendingOperation: resolvePeriodStatusForDate (company_settings + fiscal_periods) then insert
enqueue({ data: null })
enqueue({ data: null })
enqueue({ data: { id: 'op-1' }, error: null })
const result = (await vatSubmit.execute(
{ period_type: 'monthly', year: 2025, period: 3 }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { staged: boolean; risk_level: string; preview: { commit_action: string } }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('high')
expect(result.preview.commit_action).toMatch(/signering/i)
// Exactly one SKV call (the stage-time /kontrollera); no /utkast.
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/kontrollera\//)
})
})
describe('gnubok_agi_submit', () => {
it('stages from local preconditions with zero SKV calls', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'sr-1', status: 'booked', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } }) // salary_runs
enqueue({ data: { id: 'decl-1', status: 'generated', xml_content: '<agi/>' } }) // agi_declarations
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
enqueue({ data: { id: 'op-1' }, error: null }) // insert
const result = (await agiSubmit.execute(
{ salary_run_id: 'sr-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { staged: boolean; risk_level: string }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('high')
expect(mockSkvRequest).not.toHaveBeenCalled()
expect(mockKvittenser).not.toHaveBeenCalled()
})
it('throws when no AGI XML exists yet', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'sr-1', status: 'booked', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } })
enqueue({ data: null }) // no agi_declarations row
await expect(
agiSubmit.execute({ salary_run_id: 'sr-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }),
).rejects.toThrow(/AGI-underlag saknas/)
})
})
describe('Skatteverket tools — scopes', () => {
it('maps the five tools to the right scopes', () => {
expect(TOOL_SCOPE_MAP.gnubok_vat_declaration_validate).toBe('compliance:read')
expect(TOOL_SCOPE_MAP.gnubok_vat_declaration_status).toBe('compliance:read')
expect(TOOL_SCOPE_MAP.gnubok_agi_status).toBe('compliance:read')
expect(TOOL_SCOPE_MAP.gnubok_vat_declaration_submit).toBe('skatteverket:write')
expect(TOOL_SCOPE_MAP.gnubok_agi_submit).toBe('skatteverket:write')
})
it('skatteverket:write is a staging scope → SoD conflict with approve', () => {
expect(findStageApproveConflict(['skatteverket:write', 'pending_operations:approve'])).toBe('skatteverket:write')
expect(findStageApproveConflict(['skatteverket:write'])).toBeNull()
})
})
+383 -1
View File
@@ -73,11 +73,22 @@ import {
} from '@/lib/email/invoice-templates'
import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service'
import { extractInvoiceFields, ExtractionSchema as InvoiceExtractionSchema } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
// Skatteverket filing tools (PR5). Cross-extension lib import, same sanctioned
// pattern as invoice-inbox above — the CI guard only checks lib/, app/api/,
// components/. The two submit tools stage ops whose commit dispatches back into
// the skatteverket extension via the registry (lib/pending-operations/commit.ts).
import { skvRequest, SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
import { buildMomsuppgift, resolveRedovisare } from '@/extensions/general/skatteverket/lib/declaration-prep'
import { writeSkatteverketAudit } from '@/extensions/general/skatteverket/lib/audit'
import { skvAuthCodeToStructured } from '@/extensions/general/skatteverket/lib/error-map'
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { commitPendingOperation } from '@/lib/pending-operations/commit'
import { appendProcessingHistory } from '@/lib/processing-history/append'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler — no duplicate call needed here.
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation } from '@/types'
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem, PendingOperation, VatPeriodType } from '@/types'
// ── Actor context ────────────────────────────────────────────
@@ -366,6 +377,40 @@ async function stagePendingOperation(
return response
}
// ── Skatteverket filing helpers (PR5) ────────────────────────
//
// Direct lib calls bypass the HTTP dispatcher's SKATTEVERKET_ENABLED gate, so
// every Skatteverket tool gates on it first (before any DB/SKV access).
// mapSkatteverketError re-attaches a registry code to a SkatteverketAuthError
// so toToolError/getStructuredError surface the right structured envelope +
// reconnect remediation (the raw SKV codes aren't registry entries).
function assertSkatteverketEnabled(): void {
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
const err = new Error('Skatteverket-integrationen är inte aktiverad i denna miljö.') as Error & { code: string }
err.code = 'EXTENSION_DISABLED'
throw err
}
}
function mapSkatteverketError(err: unknown): Error {
if (err instanceof SkatteverketAuthError) {
const mapped = skvAuthCodeToStructured(err.code)
const out = new Error(err.message) as Error & { code: string }
out.code = mapped.code
return out
}
return err instanceof Error ? err : new Error(String(err))
}
/** YYYYMM → last day of that month as yyyy-MM-dd (for dateForPeriodCheck). */
function skvPeriodToEndDate(redovisningsperiod: string): string {
const year = Number(redovisningsperiod.slice(0, 4))
const month = Number(redovisningsperiod.slice(4, 6))
const lastDay = new Date(year, month, 0).getDate()
return `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
}
// ── Journal entry reference resolution ────────────────────────
/**
@@ -805,6 +850,42 @@ const VAT_REPORT_OUTPUT_SCHEMA = {
required: ['period', 'period_label', 'rutor', 'summary', 'warnings'],
} as const
// ── Skatteverket filing read-tool output schemas (PR5) ──
// Kept shallow (opaque object/null sub-objects) to stay within the tools/list
// payload budget; the SKV response shapes live in the extension types.
const SKV_VAT_VALIDATE_OUTPUT_SCHEMA = {
type: 'object',
properties: {
redovisare: { type: 'string', description: '12-digit redovisare' },
redovisningsperiod: { type: 'string', description: 'YYYYMM' },
momsuppgift: { type: 'object', description: 'The momsuppgift payload sent to Skatteverket' },
kontrollresultat: { type: 'object', description: 'Skatteverket kontrollresultat (status + per-ruta fel/varningar)' },
},
required: ['redovisare', 'redovisningsperiod', 'momsuppgift', 'kontrollresultat'],
} as const
const SKV_VAT_STATUS_OUTPUT_SCHEMA = {
type: 'object',
properties: {
redovisare: { type: 'string', description: '12-digit redovisare' },
redovisningsperiod: { type: 'string', description: 'YYYYMM' },
submitted: { type: ['object', 'null'], description: 'Inlämnad deklaration, or null if none on file' },
decided: { type: ['object', 'null'], description: 'Beslutad deklaration, or null if not yet decided' },
},
required: ['redovisare', 'redovisningsperiod', 'submitted', 'decided'],
} as const
const SKV_AGI_STATUS_OUTPUT_SCHEMA = {
type: 'object',
properties: {
salary_run_id: { type: 'string' },
period: { type: 'string', description: 'YYYYMM' },
local_state: { type: ['object', 'null'], description: 'Locally cached submission state' },
kvittenser: { type: ['array', 'null'], description: 'Signed receipts from Skatteverket, or null when unavailable' },
},
required: ['salary_run_id', 'period', 'local_state', 'kvittenser'],
} as const
// ── VAT report computation (shared by gnubok_get_vat_report + gnubok_vat_review_widget) ──
//
// Maps posted journal entry lines to SKV 4700 rutor. ruta49 covers domestic
@@ -6584,6 +6665,307 @@ export const tools: McpTool[] = [
},
},
// ── PR5: Skatteverket filing (external system, openWorldHint) ──────
//
// VAT (momsdeklaration) + AGI (arbetsgivardeklaration) filing from Claude.
// Reads hit SKV live (and write BFL audit rows); the two submit tools stage
// high-risk ops whose commit "sends for BankID signing" — the user's
// signature in the browser is the irreversible filing act, not the commit.
{
name: 'gnubok_vat_declaration_validate',
title: 'Validate VAT Declaration (Momsdeklaration)',
description: 'Live-validate the period momsdeklaration with Skatteverket (POST /kontrollera) — read-only at SKV, saves nothing. Returns kontrollresultat (fel/varningar per ruta) so you can fix the underlag before submitting.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' },
year: { type: 'number', description: 'Year (e.g. 2026)' },
period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' },
},
required: ['period_type', 'year', 'period'],
},
outputSchema: SKV_VAT_VALIDATE_OUTPUT_SCHEMA,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
async execute(args, companyId, userId, supabase) {
assertSkatteverketEnabled()
const periodType = args.period_type as VatPeriodType
const year = args.year as number
const period = args.period as number
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
try {
const { redovisare, redovisningsperiod, momsuppgift } =
await buildMomsuppgift(supabase, companyId, { periodType, year, period })
const res = await skvRequest(
supabase, userId, 'POST', `/kontrollera/${redovisare}/${redovisningsperiod}`, momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'kontrollera', agRegistreradId: redovisare, redovisningsperiod,
outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Skatteverket svarade med ${res.status}: ${text}`)
}
const kontrollresultat = await res.json()
return { redovisare, redovisningsperiod, momsuppgift, kontrollresultat }
} catch (err) {
throw mapSkatteverketError(err)
}
},
},
{
name: 'gnubok_vat_declaration_submit',
title: 'Submit VAT Declaration (Momsdeklaration)',
description: 'Stage the period momsdeklaration for filing with Skatteverket. High-risk — approval sends it for BankID signing (returns a signing link); it is not filed until you sign. Always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' },
year: { type: 'number', description: 'Year (e.g. 2026)' },
period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' },
},
required: ['period_type', 'year', 'period'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
async execute(args, companyId, userId, supabase, actor) {
assertSkatteverketEnabled()
const periodType = args.period_type as VatPeriodType
const year = args.year as number
const period = args.period as number
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
// Mandatory stage-time validation: the preview carries the real
// kontrollresultat and we never stage a declaration SKV would reject.
// /kontrollera is read-only on SKV's side. Shares buildMomsuppgift with
// the commit executor so preview numbers == filed numbers.
const prepared = await (async () => {
try {
const prep = await buildMomsuppgift(supabase, companyId, { periodType, year, period })
const res = await skvRequest(
supabase, userId, 'POST', `/kontrollera/${prep.redovisare}/${prep.redovisningsperiod}`, prep.momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'kontrollera', agRegistreradId: prep.redovisare, redovisningsperiod: prep.redovisningsperiod,
outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Skatteverket svarade med ${res.status}: ${text}`)
}
return { ...prep, kontrollresultat: await res.json() }
} catch (err) {
throw mapSkatteverketError(err)
}
})()
return stagePendingOperation(
supabase, companyId, userId, 'submit_vat_declaration',
`Lämna momsdeklaration: ${prepared.redovisningsperiod}`,
{ period_type: periodType, year, period },
{
redovisningsperiod: prepared.redovisningsperiod,
redovisare: prepared.redovisare,
rutor: prepared.momsuppgift,
kontrollresultat: prepared.kontrollresultat,
commit_action: 'Skickar för BankID-signering; lämnas inte in förrän du signerat.',
},
actor,
{
description: 'After approval, sign in Skatteverket via the returned BankID link, then poll gnubok_vat_declaration_status.',
tool: 'gnubok_vat_declaration_status',
args: { period_type: periodType, year, period },
},
{ dateForPeriodCheck: skvPeriodToEndDate(prepared.redovisningsperiod) },
)
},
},
{
name: 'gnubok_vat_declaration_status',
title: 'VAT Declaration Status (Momsdeklaration)',
description: 'Fetch the filing status of a momsdeklaration from Skatteverket: inlämnat (submitted) and/or beslutat (decided). Sections are null when nothing is on file yet.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
period_type: { type: 'string', enum: ['monthly', 'quarterly', 'yearly'], description: 'Period type' },
year: { type: 'number', description: 'Year (e.g. 2026)' },
period: { type: 'number', description: '1–12 for monthly, 1–4 for quarterly, 1 for yearly' },
state: { type: 'string', enum: ['submitted', 'decided', 'both'], description: "Which view to fetch. Default 'both'." },
},
required: ['period_type', 'year', 'period'],
},
outputSchema: SKV_VAT_STATUS_OUTPUT_SCHEMA,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
async execute(args, companyId, userId, supabase) {
assertSkatteverketEnabled()
const periodType = args.period_type as VatPeriodType
const year = args.year as number
const period = args.period as number
const state = (args.state as string) ?? 'both'
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
try {
const redovisare = await resolveRedovisare(supabase, companyId)
const redovisningsperiod = formatRedovisningsperiod(periodType, year, period)
let submitted: unknown = null
let decided: unknown = null
if (state === 'submitted' || state === 'both') {
const res = await skvRequest(supabase, userId, 'GET', `/inlamnat/${redovisare}/${redovisningsperiod}`)
await writeSkatteverketAudit(ctx, {
endpoint: 'inlamnat', agRegistreradId: redovisare, redovisningsperiod,
outcome: res.ok || res.status === 404 ? 'ok' : 'skv_error', responseStatus: res.status,
})
if (res.status !== 404) {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Skatteverket svarade med ${res.status}: ${text}`)
}
submitted = await res.json()
}
}
if (state === 'decided' || state === 'both') {
const res = await skvRequest(supabase, userId, 'GET', `/beslutat/${redovisare}/${redovisningsperiod}`)
await writeSkatteverketAudit(ctx, {
endpoint: 'beslutat', agRegistreradId: redovisare, redovisningsperiod,
outcome: res.ok || res.status === 404 ? 'ok' : 'skv_error', responseStatus: res.status,
})
if (res.status !== 404) {
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Skatteverket svarade med ${res.status}: ${text}`)
}
decided = await res.json()
}
}
return { redovisare, redovisningsperiod, submitted, decided }
} catch (err) {
throw mapSkatteverketError(err)
}
},
},
{
name: 'gnubok_agi_submit',
title: 'Submit AGI Declaration (Arbetsgivardeklaration)',
description: "Stage filing of a salary run's arbetsgivardeklaration (AGI) with Skatteverket. High-risk — approval posts the XML underlag and returns a BankID signing link; it is not filed until you sign. Always staged.",
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
},
required: ['salary_run_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
async execute(args, companyId, userId, supabase, actor) {
assertSkatteverketEnabled()
const salaryRunId = args.salary_run_id as string
if (!salaryRunId) throw new Error('salary_run_id is required')
// Local preconditions only — NO SKV call at stage time. The commit
// executor posts the underlag + creates the granskningsunderlag on approval.
const { data: run } = await supabase
.from('salary_runs')
.select('id, status, period_year, period_month, payment_date')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.maybeSingle()
if (!run) throw new Error('Salary run not found')
const { data: decl } = await supabase
.from('agi_declarations')
.select('id, status, xml_content')
.eq('salary_run_id', salaryRunId)
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (!decl?.xml_content) {
throw new Error('AGI-underlag saknas — generera AGI först med gnubok_generate_agi.')
}
const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
return stagePendingOperation(
supabase, companyId, userId, 'submit_agi',
`Lämna AGI: ${period}`,
{ salary_run_id: salaryRunId },
{
period,
salary_run_status: run.status,
agi_declaration_id: decl.id,
retention_years: 7,
commit_action: 'Skickar underlag + returnerar BankID-signeringslänk; lämnas inte in förrän du signerat.',
},
actor,
{
description: 'After approval, sign via the returned BankID link, then poll gnubok_agi_status.',
tool: 'gnubok_agi_status',
args: { salary_run_id: salaryRunId },
},
run.payment_date ? { dateForPeriodCheck: run.payment_date } : {},
)
},
},
{
name: 'gnubok_agi_status',
title: 'AGI Declaration Status (Arbetsgivardeklaration)',
description: 'Fetch AGI filing status for a salary run: local submission state plus live Skatteverket kvittenser (signed receipts) when available. Returns kvittensnummer/signeradTid once the AGI has been signed.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
},
required: ['salary_run_id'],
},
outputSchema: SKV_AGI_STATUS_OUTPUT_SCHEMA,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
async execute(args, companyId, userId, supabase) {
assertSkatteverketEnabled()
const salaryRunId = args.salary_run_id as string
if (!salaryRunId) throw new Error('salary_run_id is required')
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
try {
const { data: run } = await supabase
.from('salary_runs')
.select('id, period_year, period_month')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.maybeSingle()
if (!run) throw new Error('Salary run not found')
const arbetsgivare = await resolveRedovisare(supabase, companyId)
const period = formatRedovisningsperiod('monthly', run.period_year, run.period_month)
// Local cached submission state (extension_data key agi_submission_${period}).
const { data: localRow } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', `agi_submission_${period}`)
.maybeSingle()
let localState: unknown = null
if (localRow?.value) {
try { localState = JSON.parse(localRow.value as string) } catch { localState = null }
}
// Live kvittenser (read-only). A non-ok read (e.g. nothing filed yet)
// leaves kvittenser null rather than hard-failing the status check;
// auth errors throw and map to SKATTEVERKET_NOT_CONNECTED.
let kvittenser: unknown = null
const res = await agiGetKvittenser(supabase, userId, arbetsgivare, period)
await writeSkatteverketAudit(ctx, {
endpoint: 'kvittenser', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: res.ok ? 'ok' : 'skv_error', responseStatus: res.status,
})
if (res.ok) kvittenser = res.data.kvittenser
return { salary_run_id: salaryRunId, period, local_state: localState, kvittenser }
} catch (err) {
throw mapSkatteverketError(err)
}
},
},
// ── Stream 1 Phase 1: Bookkeeping write (high-risk, always staged) ──
{
@@ -0,0 +1,94 @@
/**
* Tests for the extension's registry-exposed commit services — specifically the
* VAT "send for signing" chain (POST /utkast → PUT /las → signeringslänk), the
* SKATTEVERKET_ENABLED flag gate, and SkatteverketAuthError → recoverable
* mapping. The op-lifecycle translation is covered separately in
* lib/pending-operations/__tests__/skatteverket-executors.test.ts.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockSkvRequest = vi.fn()
vi.mock('../lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, skvRequest: (...a: unknown[]) => mockSkvRequest(...a) }
})
const mockBuildMomsuppgift = vi.fn()
vi.mock('../lib/declaration-prep', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, buildMomsuppgift: (...a: unknown[]) => mockBuildMomsuppgift(...a) }
})
vi.mock('../lib/audit', () => ({ writeSkatteverketAudit: vi.fn() }))
vi.mock('@/lib/extensions/context-factory', () => ({
createExtensionContext: () => ({
supabase: {},
companyId: 'company-1',
userId: 'user-1',
settings: { set: vi.fn().mockResolvedValue(undefined) },
log: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}),
}))
import { skatteverketExtension } from '../index'
import { SkatteverketAuthError } from '../lib/api-client'
type SkvSubmitFn = (
supabase: unknown, userId: string, companyId: string, params: Record<string, unknown>,
) => Promise<{ ok: boolean; code?: string; recoverable?: boolean; signing_url?: string }>
const commitSubmitVatDeclaration = skatteverketExtension.services!.commitSubmitVatDeclaration as unknown as SkvSubmitFn
const VAT_PARAMS = { period_type: 'monthly', year: 2025, period: 3 }
let prevEnv: string | undefined
beforeEach(() => {
vi.clearAllMocks()
prevEnv = process.env.SKATTEVERKET_ENABLED
process.env.SKATTEVERKET_ENABLED = 'true'
mockBuildMomsuppgift.mockResolvedValue({
redovisare: '165560000000', redovisningsperiod: '202503', momsuppgift: { summaMoms: 150 },
})
})
afterEach(() => {
if (prevEnv === undefined) delete process.env.SKATTEVERKET_ENABLED
else process.env.SKATTEVERKET_ENABLED = prevEnv
})
describe('commitSubmitVatDeclaration', () => {
it('flag off → recoverable EXTENSION_DISABLED, zero SKV calls', async () => {
delete process.env.SKATTEVERKET_ENABLED
const result = await commitSubmitVatDeclaration({}, 'user-1', 'company-1', VAT_PARAMS)
expect(result).toMatchObject({ ok: false, code: 'EXTENSION_DISABLED', recoverable: true })
expect(mockSkvRequest).not.toHaveBeenCalled()
})
it('happy path: POST /utkast then PUT /las → ok with signing_url', async () => {
mockSkvRequest
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ kontrollResultat: { status: 'OK' } }) }) // utkast
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ signeringsLank: 'https://skv.test/sign/abc' }) }) // las
const result = await commitSubmitVatDeclaration({}, 'user-1', 'company-1', VAT_PARAMS)
expect(result).toMatchObject({ ok: true, signing_url: 'https://skv.test/sign/abc' })
expect(mockSkvRequest).toHaveBeenCalledTimes(2)
// call order: utkast (POST) before las (PUT)
expect(mockSkvRequest.mock.calls[0][2]).toBe('POST')
expect(mockSkvRequest.mock.calls[0][3]).toMatch(/^\/utkast\/165560000000\/202503$/)
expect(mockSkvRequest.mock.calls[1][2]).toBe('PUT')
expect(mockSkvRequest.mock.calls[1][3]).toMatch(/^\/las\/165560000000\/202503$/)
})
it('utkast rejected by SKV → non-recoverable, no /las call', async () => {
mockSkvRequest.mockResolvedValueOnce({ ok: false, status: 400, text: async () => 'bad rutor' })
const result = await commitSubmitVatDeclaration({}, 'user-1', 'company-1', VAT_PARAMS)
expect(result).toMatchObject({ ok: false, recoverable: false, http_status: 400 })
expect(mockSkvRequest).toHaveBeenCalledTimes(1) // never reached /las
})
it('SkatteverketAuthError → recoverable SKATTEVERKET_NOT_CONNECTED', async () => {
mockSkvRequest.mockRejectedValueOnce(new SkatteverketAuthError('ingen anslutning', 'NOT_CONNECTED'))
const result = await commitSubmitVatDeclaration({}, 'user-1', 'company-1', VAT_PARAMS)
expect(result).toMatchObject({ ok: false, code: 'SKATTEVERKET_NOT_CONNECTED', recoverable: true })
})
})
@@ -0,0 +1,110 @@
/**
* Tests for the shared declaration-prep functions. These are the single
* source of truth for what gets filed to Skatteverket — the HTTP route
* handlers and the commit-side services both go through them, so a regression
* here would mean different numbers filed than the user reviewed (no-drift
* compliance guarantee).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { VatDeclarationRutor } from '@/types'
const mockCalculateVatDeclaration = vi.fn()
vi.mock('@/lib/reports/vat-declaration', () => ({
calculateVatDeclaration: (...a: unknown[]) => mockCalculateVatDeclaration(...a),
}))
import { buildMomsuppgift, buildAgiUnderlag, resolveRedovisare } from '../lib/declaration-prep'
import { rutorToMomsuppgift } from '../lib/mappers'
const READ_KEYS = [
'ruta05', 'ruta06', 'ruta07', 'ruta08', 'ruta10', 'ruta11', 'ruta12',
'ruta20', 'ruta21', 'ruta22', 'ruta23', 'ruta24', 'ruta30', 'ruta31', 'ruta32',
'ruta35', 'ruta36', 'ruta37', 'ruta38', 'ruta39', 'ruta40', 'ruta41', 'ruta42',
'ruta48', 'ruta50', 'ruta60', 'ruta61', 'ruta62',
]
function zeroRutor(): VatDeclarationRutor {
return Object.fromEntries(READ_KEYS.map((k) => [k, 0])) as unknown as VatDeclarationRutor
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('resolveRedovisare', () => {
it('formats an aktiebolag org number to the 12-digit redovisare', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } })
const redovisare = await resolveRedovisare(supabase as never, 'company-1')
expect(redovisare).toBe('165560000000')
})
it('throws when org number is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { org_number: null, entity_type: 'aktiebolag' } })
await expect(resolveRedovisare(supabase as never, 'company-1')).rejects.toThrow(/Organisationsnummer saknas/)
})
})
describe('buildMomsuppgift', () => {
it('produces the same momsuppgift the route handler would (rutorToMomsuppgift over the GL rutor)', async () => {
const rutor = zeroRutor()
rutor.ruta10 = 250 // output VAT 25%
rutor.ruta48 = 100 // input VAT
mockCalculateVatDeclaration.mockResolvedValue({ rutor })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } }) // resolveRedovisare
const result = await buildMomsuppgift(supabase as never, 'company-1', { periodType: 'monthly', year: 2025, period: 3 })
expect(result.redovisare).toBe('165560000000')
expect(result.redovisningsperiod).toBe('202503')
// Identical to the direct mapper output — locks the no-drift guarantee.
expect(result.momsuppgift).toEqual(rutorToMomsuppgift(rutor))
expect(result.momsuppgift.momsForsaljningUtgaendeHog).toBe(250)
expect(result.momsuppgift.ingaendeMomsAvdrag).toBe(100)
expect(result.momsuppgift.summaMoms).toBe(150)
expect(mockCalculateVatDeclaration).toHaveBeenCalledWith(expect.anything(), 'company-1', 'monthly', 2025, 3)
})
})
describe('buildAgiUnderlag', () => {
it('loads the latest XML and formats arbetsgivare + period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { status: 'booked' } }) // salary_runs status guard
enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } }) // resolveRedovisare
enqueue({ data: { xml_content: '<agi/>', period_year: 2026, period_month: 3 } }) // agi_declarations
const result = await buildAgiUnderlag(supabase as never, 'company-1', 'sr-1')
expect(result).toMatchObject({
arbetsgivare: '165560000000',
period: '202603',
salaryRunId: 'sr-1',
xml: '<agi/>',
periodYear: 2026,
periodMonth: 3,
})
})
it('throws when the salary run is not past draft', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { status: 'draft' } })
await expect(buildAgiUnderlag(supabase as never, 'company-1', 'sr-1')).rejects.toThrow(/efter granskning/)
})
it('throws when salaryRunId is missing', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(buildAgiUnderlag(supabase as never, 'company-1', '')).rejects.toThrow(/salaryRunId/)
})
it('throws when no AGI XML exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { status: 'booked' } })
enqueue({ data: { org_number: '5560000000', entity_type: 'aktiebolag' } })
enqueue({ data: { xml_content: null, period_year: 2026, period_month: 3 } })
await expect(buildAgiUnderlag(supabase as never, 'company-1', 'sr-1')).rejects.toThrow(/AGI-XML saknas/)
})
})
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { skvAuthCodeToStructured } from '../lib/error-map'
import { getErrorEntry } from '@/lib/errors/structured-errors'
describe('skvAuthCodeToStructured', () => {
const reconnectCodes = [
'NOT_CONNECTED', 'SESSION_EXPIRED', 'REFRESH_EXHAUSTED', 'TOKEN_REVOKED', 'TOKEN_CORRUPTED', 'MISSING_SCOPE',
] as const
for (const code of reconnectCodes) {
it(`${code} → SKATTEVERKET_NOT_CONNECTED (401)`, () => {
expect(skvAuthCodeToStructured(code)).toEqual({ code: 'SKATTEVERKET_NOT_CONNECTED', httpStatus: 401 })
})
}
for (const code of ['BEHORIGHET_SAKNAS', 'ACCESS_DENIED'] as const) {
it(`${code} → SKATTEVERKET_ACCESS_DENIED (403)`, () => {
expect(skvAuthCodeToStructured(code)).toEqual({ code: 'SKATTEVERKET_ACCESS_DENIED', httpStatus: 403 })
})
}
it('RATE_LIMITED → SKATTEVERKET_RATE_LIMITED (429)', () => {
expect(skvAuthCodeToStructured('RATE_LIMITED')).toEqual({ code: 'SKATTEVERKET_RATE_LIMITED', httpStatus: 429 })
})
it('every mapped structured code resolves to a real registry entry', () => {
for (const code of ['SKATTEVERKET_NOT_CONNECTED', 'SKATTEVERKET_ACCESS_DENIED', 'SKATTEVERKET_RATE_LIMITED', 'EXTENSION_DISABLED']) {
expect(getErrorEntry(code), code).toBeDefined()
}
})
})
+296 -156
View File
@@ -1,4 +1,5 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import {
@@ -10,8 +11,16 @@ import { TimeoutError } from '@/lib/http/fetch-with-timeout'
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import { writeSkatteverketAudit } from './lib/audit'
import { skvAuthCodeToStructured } from './lib/error-map'
import {
buildMomsuppgift,
buildAgiUnderlag,
type VatDeclarationPrep,
type AgiUnderlagPrep,
} from './lib/declaration-prep'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import type { SkvSubmitResult } from '@/lib/pending-operations/skatteverket-commit'
import {
agiPostUnderlag,
agiGetKontrollresultat,
@@ -35,7 +44,7 @@ import {
SkattekontoMatchError,
} from './lib/skattekonto-match'
import { splitTransactions } from './lib/skattekonto-buckets'
import type { SkattekontoBalanceSnapshot } from './types'
import type { SkattekontoBalanceSnapshot, SkatteverketUtkastResponse } from './types'
import type { VatPeriodType } from '@/types'
/**
@@ -131,57 +140,6 @@ async function requireAgiWriteRole(ctx: ExtensionContext): Promise<NextResponse
return null
}
/**
* Append an immutable row to skatteverket_api_audit_log. Errors are
* swallowed (logged only) so an audit-table outage does not break the
* regulator flow — but a successful primary call without an audit row
* shows up as a noisy console.error for ops to investigate.
*/
async function writeSkatteverketAudit(
ctx: ExtensionContext,
fields: {
endpoint: string
agRegistreradId?: string | null
redovisningsperiod?: string | null
outcome: 'ok' | 'validation_error' | 'skv_error' | 'auth_error' | 'internal_error'
responseStatus?: number | null
skvStatus?: string | null
requestSizeBytes?: number | null
correlationId?: string | null
errorMessage?: string | null
},
): Promise<void> {
try {
const { error } = await ctx.supabase
.from('skatteverket_api_audit_log')
.insert({
company_id: ctx.companyId,
user_id: ctx.userId,
endpoint: fields.endpoint,
ag_registered_id: fields.agRegistreradId ?? null,
redovisningsperiod: fields.redovisningsperiod ?? null,
outcome: fields.outcome,
response_status: fields.responseStatus ?? null,
skv_status: fields.skvStatus ?? null,
request_size_bytes: fields.requestSizeBytes ?? null,
correlation_id: fields.correlationId ?? null,
error_message: fields.errorMessage ?? null,
})
if (error) {
ctx.log.error('skatteverket_api_audit_log insert failed', {
endpoint: fields.endpoint,
outcome: fields.outcome,
error: error.message,
})
}
} catch (err) {
ctx.log.error('skatteverket_api_audit_log insert threw', {
endpoint: fields.endpoint,
error: err instanceof Error ? err.message : String(err),
})
}
}
export const skatteverketExtension: Extension = {
id: 'skatteverket',
name: 'Skatteverket Integration',
@@ -1901,22 +1859,32 @@ export const skatteverketExtension: Extension = {
handler: handleSkattekontoDriftDetected,
},
],
// Registry-resolved commit services for the MCP submit tools. The core
// pending-operations dispatcher (lib/pending-operations/commit.ts) cannot
// import this extension (CI guard), so it reaches these through
// extensionRegistry.get('skatteverket')?.services when committing a staged
// submit_vat_declaration / submit_agi operation. Commit = "send for BankID
// signing" (returns a signing link), never "file".
services: {
commitSubmitVatDeclaration,
commitSubmitAgi,
},
}
// ── Helpers ───────────────────────────────────────────────────────────
/**
* Parse and validate declaration request body.
* Computes momsuppgift from Accounted's VAT calculation if not provided directly.
* Parse and validate declaration request body, then compute the momsuppgift.
*
* The computation itself lives in lib/declaration-prep.ts (buildMomsuppgift)
* so the commit-side service and MCP tools file exactly the same numbers this
* route does — see the no-drift note there. This shell only parses the body.
*/
async function parseDeclarationRequest(
request: Request,
ctx: ExtensionContext
): Promise<{
redovisare: string
redovisningsperiod: string
momsuppgift: ReturnType<typeof rutorToMomsuppgift>
}> {
): Promise<VatDeclarationPrep> {
const body = await request.json()
const { periodType, year, period } = body as {
periodType: VatPeriodType
@@ -1928,32 +1896,7 @@ async function parseDeclarationRequest(
throw new Error('Saknar obligatoriska fält: periodType, year, period')
}
// Get company settings for redovisare formatting
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', ctx.companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
const redovisare = formatRedovisare(settings.org_number, settings.entity_type)
const redovisningsperiod = formatRedovisningsperiod(periodType, year, period)
// Calculate VAT declaration from the general ledger
const declaration = await calculateVatDeclaration(
ctx.supabase,
ctx.companyId,
periodType,
year,
period
)
const momsuppgift = rutorToMomsuppgift(declaration.rutor)
return { redovisare, redovisningsperiod, momsuppgift }
return buildMomsuppgift(ctx.supabase, ctx.companyId, { periodType, year, period })
}
/**
@@ -1982,80 +1925,16 @@ function parseQueryParams(
* Load the AGI XML for a salary run from agi_declarations.xml_content
* (built by app/api/salary/runs/[id]/agi/xml/route.ts via generateAGIXml).
*
* Returns the XML alongside the formatted arbetsgivare/period strings used
* downstream by the granskningsunderlag and kvittenser endpoints.
*
* Skatteverket's POST /underlag accepts XML directly; we don't transform it
* here, just plumb it through.
* The lookup + status guard live in lib/declaration-prep.ts (buildAgiUnderlag)
* so the commit-side service files the same XML this route does. This shell
* only parses the salaryRunId from the body.
*/
async function loadAGIXml(
request: Request,
ctx: ExtensionContext,
): Promise<{
arbetsgivare: string
period: string
salaryRunId: string
xml: string
}> {
): Promise<AgiUnderlagPrep> {
const body = (await request.json()) as { salaryRunId?: string }
const salaryRunId = body.salaryRunId
if (!salaryRunId) {
throw new Error('Saknar obligatoriskt fält: salaryRunId')
}
// Status guard — must mirror the orchestrator at
// app/api/salary/runs/[id]/agi/submit/route.ts. The extension endpoint
// is also reachable directly from AGIPanel, so the check has to live here
// too. Per BFL 5 kap and SFL 26 kap, AGI must reflect finalised payroll
// data; submitting from a draft/cancelled run would emit incorrect figures
// and require a costly rättelse.
const { data: run, error: runError } = await ctx.supabase
.from('salary_runs')
.select('status')
.eq('id', salaryRunId)
.eq('company_id', ctx.companyId)
.single()
if (runError || !run) {
throw new Error('Lönekörning hittades inte')
}
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
throw new Error('AGI kan bara skickas till Skatteverket efter granskning')
}
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', ctx.companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
// Use the most recent agi_declarations row for this salary run — covers
// both new declarations and corrections (which overwrite xml_content
// in place per the existing /api/salary/runs/[id]/agi/xml route).
const { data: declaration, error: declarationError } = await ctx.supabase
.from('agi_declarations')
.select('xml_content, period_year, period_month')
.eq('company_id', ctx.companyId)
.eq('salary_run_id', salaryRunId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (declarationError || !declaration?.xml_content) {
throw new Error(
'AGI-XML saknas. Generera AGI-filen från lönekörningen först (Lön → AGI → Generera).',
)
}
const arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
const period = formatRedovisningsperiod('monthly', declaration.period_year, declaration.period_month)
return { arbetsgivare, period, salaryRunId, xml: declaration.xml_content }
return buildAgiUnderlag(ctx.supabase, ctx.companyId, body.salaryRunId ?? '')
}
/**
@@ -2087,3 +1966,264 @@ function handleSkvError(err: unknown): NextResponse {
{ status: 500 }
)
}
// ── MCP submit commit services ─────────────────────────────────────────
//
// Registry-resolved by lib/pending-operations/commit.ts when a staged
// submit_vat_declaration / submit_agi op is approved. "Commit" runs the SKV
// chain up to the BankID signing link and returns it — the user's signature in
// the browser is the irreversible filing act, outside this code.
//
// Direct lib calls bypass the HTTP dispatcher's SKATTEVERKET_ENABLED gate
// (app/api/extensions/ext/[...path]/route.ts), so each service checks the flag
// itself and returns a recoverable EXTENSION_DISABLED result (the op stays
// reviewable). SkatteverketAuthError (no connection / scope / quota) is
// likewise recoverable. SKV business rejections are non-recoverable → the op
// is consumed and the user regenerates + re-stages.
function skatteverketEnabled(): boolean {
return process.env.SKATTEVERKET_ENABLED === 'true'
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
const EXTENSION_DISABLED_RESULT: Extract<SkvSubmitResult, { ok: false }> = {
ok: false,
code: 'EXTENSION_DISABLED',
http_status: 503,
recoverable: true,
error: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
}
/**
* Translate a thrown error inside a commit service to a SkvSubmitResult.
* SkatteverketAuthError (connection / scope / quota) is recoverable — the op
* stays reviewable so the user reconnects and re-approves. Anything else is a
* non-recoverable internal error → the op is rejected.
*/
async function mapServiceError(
ctx: ExtensionContext,
endpoint: string,
err: unknown,
): Promise<Extract<SkvSubmitResult, { ok: false }>> {
if (err instanceof SkatteverketAuthError) {
const mapped = skvAuthCodeToStructured(err.code)
await writeSkatteverketAudit(ctx, { endpoint, outcome: 'auth_error', errorMessage: err.message })
return { ok: false, code: mapped.code, http_status: mapped.httpStatus, recoverable: true, error: err.message }
}
await writeSkatteverketAudit(ctx, {
endpoint,
outcome: 'internal_error',
errorMessage: err instanceof Error ? err.message : String(err),
})
return {
ok: false,
code: 'SKATTEVERKET_INTERNAL_ERROR',
http_status: 500,
recoverable: false,
error: err instanceof Error ? err.message : 'Okänt fel',
}
}
/**
* VAT "skicka för signering": POST /utkast + PUT /las → signeringslänk.
* Recompute-at-commit (buildMomsuppgift over posted entries) so the figures
* filed equal what the preview showed for the same ledger state.
*/
async function commitSubmitVatDeclaration(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<SkvSubmitResult> {
if (!skatteverketEnabled()) return EXTENSION_DISABLED_RESULT
const periodType = params.period_type as VatPeriodType
const year = params.year as number
const period = params.period as number
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
try {
const { redovisare, redovisningsperiod, momsuppgift } =
await buildMomsuppgift(supabase, companyId, { periodType, year, period })
// 1. POST /utkast — save the draft to Eget utrymme. Overwrites any prior
// draft for the period, so retry after a mid-chain failure is safe.
const utkast = await skvRequest(
supabase, userId, 'POST', `/utkast/${redovisare}/${redovisningsperiod}`, momsuppgift,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/draft', agRegistreradId: redovisare, redovisningsperiod,
outcome: utkast.ok ? 'ok' : 'skv_error', responseStatus: utkast.status,
})
if (!utkast.ok) {
const text = await utkast.text().catch(() => '')
return {
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: utkast.status,
recoverable: false, error: `Skatteverket svarade med ${utkast.status}: ${text}`,
}
}
const utkastData = (await utkast.json()) as SkatteverketUtkastResponse
// 2. PUT /las — lock for signing; returns the BankID signeringslänk.
const las = await skvRequest(
supabase, userId, 'PUT', `/las/${redovisare}/${redovisningsperiod}`,
)
await writeSkatteverketAudit(ctx, {
endpoint: 'declaration/lock', agRegistreradId: redovisare, redovisningsperiod,
outcome: las.ok ? 'ok' : 'skv_error', responseStatus: las.status,
})
if (!las.ok) {
const text = await las.text().catch(() => '')
return {
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: las.status,
recoverable: false, error: `Skatteverket svarade med ${las.status}: ${text}`,
}
}
const lasData = (await las.json()) as SkatteverketUtkastResponse
if (!lasData.signeringsLank) {
return {
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: 502, recoverable: false,
error: 'Skatteverket låste deklarationen men returnerade ingen signeringslänk.',
}
}
// Persist locked state so the UI/poller can resume (mirrors /declaration/lock).
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_locked', redovisare, redovisningsperiod,
signeringsLank: lasData.signeringsLank, updatedAt: new Date().toISOString(),
}),
)
return {
ok: true,
signing_url: lasData.signeringsLank,
redovisningsperiod,
redovisare,
kontrollresultat: utkastData.kontrollResultat ?? null,
}
} catch (err) {
return mapServiceError(ctx, 'declaration/submit', err)
}
}
/**
* AGI "skicka för signering": POST /underlag → poll kontrollresultat →
* skapaGranskningsunderlag(lasPeriod) → Mina Sidor signing link. Mirrors the
* route handlers' status flips (monotonic guards) and audit rows.
*/
async function commitSubmitAgi(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<SkvSubmitResult> {
if (!skatteverketEnabled()) return EXTENSION_DISABLED_RESULT
const salaryRunId = params.salary_run_id as string
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
try {
const { arbetsgivare, period, xml, periodYear, periodMonth } =
await buildAgiUnderlag(supabase, companyId, salaryRunId)
// 1. POST /underlag (XML) → inlamningId.
const submit = await agiPostUnderlag(supabase, userId, xml)
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/submit', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: submit.ok ? 'ok' : 'skv_error', responseStatus: submit.status,
errorMessage: submit.ok ? null : submit.error,
})
if (!submit.ok) {
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: submit.status,
recoverable: false, error: submit.error }
}
const inlamningId = submit.data.inlamningId
await ctx.settings.set(`agi_submission_${period}`, JSON.stringify({
status: 'underlag_submitted', arbetsgivare, period, salaryRunId,
inlamningId, updatedAt: new Date().toISOString(),
}))
// 2. Poll kontrollresultat (bounded; SKV is typically sub-second).
let kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
for (let i = 0; i < 2 && kontroll.ok && kontroll.data.status === 'PROCESSING'; i++) {
await sleep(750)
kontroll = await agiGetKontrollresultat(supabase, userId, inlamningId)
}
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/kontrollresultat', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: kontroll.ok ? 'ok' : 'skv_error', responseStatus: kontroll.status,
skvStatus: kontroll.ok ? kontroll.data.status : null,
})
if (!kontroll.ok) {
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: kontroll.status,
recoverable: false, error: kontroll.error }
}
if (kontroll.data.status === 'PROCESSING') {
// Still processing after the bounded poll — recoverable; re-approve shortly.
return { ok: false, code: 'SKATTEVERKET_PROCESSING', http_status: 202, recoverable: true,
error: 'Skatteverket bearbetar fortfarande AGI-underlaget. Försök igen om en stund.' }
}
if (kontroll.data.status === 'DONE_REJECTED' || kontroll.data.status === 'DONE_FAILED') {
// Scope by salary_run_id, not just period: a correction run sharing the
// period must not have its (still-valid) declaration flipped to rejected.
await supabase.from('agi_declarations').update({ status: 'rejected' })
.eq('company_id', companyId).eq('salary_run_id', salaryRunId)
.eq('period_year', periodYear).eq('period_month', periodMonth)
.in('status', ['generated', 'pending_signature', 'exported'])
return { ok: false, code: 'AGI_KONTROLL_REJECTED', http_status: 422, recoverable: false,
error: 'Skatteverket avvisade AGI-underlaget vid kontroll. Åtgärda felen och generera om AGI:n.' }
}
// 3. skapaGranskningsunderlag (lasPeriod=true) → Mina Sidor signing link.
const gransk = await agiSkapaGranskningsunderlag(supabase, userId, arbetsgivare, period, { lasPeriod: true })
await writeSkatteverketAudit(ctx, {
endpoint: 'agi/granskningsunderlag', agRegistreradId: arbetsgivare, redovisningsperiod: period,
outcome: gransk.ok ? 'ok' : 'skv_error', responseStatus: gransk.status,
skvStatus: gransk.ok ? gransk.data.tillstand : null,
})
if (!gransk.ok) {
return { ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: gransk.status,
recoverable: false, error: gransk.error }
}
const tillstand = gransk.data.tillstand
const canSign = tillstand === 'LOCKED_FOR_SIGNING' || tillstand === 'UNLOCKED'
await ctx.settings.set(`agi_submission_${period}`, JSON.stringify({
status: canSign ? 'awaiting_signing' : 'underlag_rejected', arbetsgivare, period,
signeringslank: gransk.data.link, tillstand, meddelande: gransk.data.meddelande,
updatedAt: new Date().toISOString(),
}))
if (tillstand === 'INCORRECT_DATA') {
return { ok: false, code: 'AGI_GRANSKNING_INCORRECT', http_status: 422, recoverable: false,
error: gransk.data.meddelande || 'Skatteverket avvisade granskningsunderlaget. Åtgärda felen och generera om AGI:n.' }
}
if (!canSign) {
// RECEIVING / CALCULATING etc. — still processing, recoverable.
return { ok: false, code: 'SKATTEVERKET_PROCESSING', http_status: 202, recoverable: true,
error: gransk.data.meddelande || 'Skatteverket bearbetar fortfarande underlaget. Försök igen om en stund.' }
}
// Flip the declaration to pending_signature (monotonic guard). Scoped by
// salary_run_id so a correction run in the same period isn't co-flipped —
// more precise than the period-only route handler, which has no run id.
await supabase.from('agi_declarations').update({ status: 'pending_signature' })
.eq('company_id', companyId).eq('salary_run_id', salaryRunId)
.eq('period_year', periodYear).eq('period_month', periodMonth)
.in('status', ['generated', 'rejected'])
return {
ok: true,
signing_url: gransk.data.link,
arbetsgivare,
period,
inlamning_id: inlamningId,
tillstand,
}
} catch (err) {
return mapServiceError(ctx, 'agi/submit', err)
}
}
@@ -0,0 +1,59 @@
import type { ExtensionContext } from '@/lib/extensions/types'
/**
* Append an immutable row to skatteverket_api_audit_log. Errors are
* swallowed (logged only) so an audit-table outage does not break the
* regulator flow — but a successful primary call without an audit row
* shows up as a noisy console.error for ops to investigate.
*
* Lives in its own module so the route handlers (which pass a real
* ExtensionContext), the commit-side services, and the MCP read tools can all
* share one audit writer. Callers that only hold (supabase, userId, companyId)
* build a context with `createExtensionContext(supabase, userId, companyId,
* 'skatteverket')` — cheap, no I/O — and pass it here. The `(ctx, fields)`
* signature is preserved verbatim so the existing handlers stay byte-identical.
*/
export async function writeSkatteverketAudit(
ctx: ExtensionContext,
fields: {
endpoint: string
agRegistreradId?: string | null
redovisningsperiod?: string | null
outcome: 'ok' | 'validation_error' | 'skv_error' | 'auth_error' | 'internal_error'
responseStatus?: number | null
skvStatus?: string | null
requestSizeBytes?: number | null
correlationId?: string | null
errorMessage?: string | null
},
): Promise<void> {
try {
const { error } = await ctx.supabase
.from('skatteverket_api_audit_log')
.insert({
company_id: ctx.companyId,
user_id: ctx.userId,
endpoint: fields.endpoint,
ag_registered_id: fields.agRegistreradId ?? null,
redovisningsperiod: fields.redovisningsperiod ?? null,
outcome: fields.outcome,
response_status: fields.responseStatus ?? null,
skv_status: fields.skvStatus ?? null,
request_size_bytes: fields.requestSizeBytes ?? null,
correlation_id: fields.correlationId ?? null,
error_message: fields.errorMessage ?? null,
})
if (error) {
ctx.log.error('skatteverket_api_audit_log insert failed', {
endpoint: fields.endpoint,
outcome: fields.outcome,
error: error.message,
})
}
} catch (err) {
ctx.log.error('skatteverket_api_audit_log insert threw', {
endpoint: fields.endpoint,
error: err instanceof Error ? err.message : String(err),
})
}
}
@@ -0,0 +1,152 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { VatPeriodType } from '@/types'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './mappers'
import type { SkatteverketMomsuppgift } from '../types'
/**
* Request-free Skatteverket declaration prep.
*
* These functions are the single source of truth for what gets filed to
* Skatteverket. They are shared by the HTTP route handlers
* (parseDeclarationRequest / loadAGIXml) and the commit-side services
* (commitSubmitVatDeclaration / commitSubmitAgi) so the numbers and XML
* computed at preview time match exactly what is filed at commit time.
*
* Compliance-critical: drift between the two paths would mean different
* figures filed to SKV than the user reviewed. Keep these the only place that
* computes momsuppgift / loads AGI XML.
*/
export interface VatDeclarationPrep {
redovisare: string
redovisningsperiod: string
momsuppgift: SkatteverketMomsuppgift
}
export interface AgiUnderlagPrep {
arbetsgivare: string
period: string // YYYYMM
salaryRunId: string
xml: string
periodYear: number
periodMonth: number
}
/**
* Resolve a company's 12-digit "redovisare" string from company_settings.
* Shared by the VAT and AGI paths and by the status tools that only need the
* identifier (no momsuppgift / XML compute).
*/
export async function resolveRedovisare(
supabase: SupabaseClient,
companyId: string,
): Promise<string> {
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
return formatRedovisare(settings.org_number, settings.entity_type)
}
/**
* Compute the momsuppgift filed to SKV for a period, from the general ledger.
* Body lifted verbatim from the former parseDeclarationRequest so route and
* commit paths produce identical payloads.
*/
export async function buildMomsuppgift(
supabase: SupabaseClient,
companyId: string,
input: { periodType: VatPeriodType; year: number; period: number },
): Promise<VatDeclarationPrep> {
const { periodType, year, period } = input
const redovisare = await resolveRedovisare(supabase, companyId)
const redovisningsperiod = formatRedovisningsperiod(periodType, year, period)
// Calculate VAT declaration from the general ledger
const declaration = await calculateVatDeclaration(
supabase,
companyId,
periodType,
year,
period,
)
const momsuppgift = rutorToMomsuppgift(declaration.rutor)
return { redovisare, redovisningsperiod, momsuppgift }
}
/**
* Load the AGI XML for a salary run from agi_declarations.xml_content
* (built by app/api/salary/runs/[id]/agi/xml/route.ts via generateAGIXml),
* alongside the formatted arbetsgivare/period strings used downstream by the
* granskningsunderlag and kvittenser calls.
*
* Body lifted verbatim from the former loadAGIXml — including the salary-run
* status guard (per BFL 5 kap and SFL 26 kap, AGI must reflect finalised
* payroll data; submitting from a draft/cancelled run would emit incorrect
* figures and require a costly rättelse).
*/
export async function buildAgiUnderlag(
supabase: SupabaseClient,
companyId: string,
salaryRunId: string,
): Promise<AgiUnderlagPrep> {
if (!salaryRunId) {
throw new Error('Saknar obligatoriskt fält: salaryRunId')
}
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('status')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.single()
if (runError || !run) {
throw new Error('Lönekörning hittades inte')
}
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
throw new Error('AGI kan bara skickas till Skatteverket efter granskning')
}
const arbetsgivare = await resolveRedovisare(supabase, companyId)
// Use the most recent agi_declarations row for this salary run — covers
// both new declarations and corrections (which overwrite xml_content
// in place per the existing /api/salary/runs/[id]/agi/xml route).
const { data: declaration, error: declarationError } = await supabase
.from('agi_declarations')
.select('xml_content, period_year, period_month')
.eq('company_id', companyId)
.eq('salary_run_id', salaryRunId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (declarationError || !declaration?.xml_content) {
throw new Error(
'AGI-XML saknas. Generera AGI-filen från lönekörningen först (Lön → AGI → Generera).',
)
}
const period = formatRedovisningsperiod('monthly', declaration.period_year, declaration.period_month)
return {
arbetsgivare,
period,
salaryRunId,
xml: declaration.xml_content,
periodYear: declaration.period_year,
periodMonth: declaration.period_month,
}
}
@@ -0,0 +1,41 @@
import type { SkatteverketAuthError } from './api-client'
export interface StructuredSkvError {
code: string
httpStatus: number
}
/**
* Map a SkatteverketAuthError.code to the structured error code + HTTP status
* used across the MCP surface (registry: lib/errors/structured-errors.ts).
*
* Shared by the commit-side services (extensions/general/skatteverket/index.ts)
* and the MCP tools (extensions/general/mcp-server/server.ts) so connection
* failures surface one consistent reconnect remediation everywhere.
*
* Every auth code is recoverable in the commit sense — the op is fine, the
* connection/scope/quota isn't — so callers reconnect (or wait) and retry the
* same operation. The three buckets collapse the nine raw SKV codes onto the
* remediation that actually differs: reconnect with BankID, fix authorisation
* at SKV, or back off.
*/
export function skvAuthCodeToStructured(
code: SkatteverketAuthError['code'],
): StructuredSkvError {
switch (code) {
case 'NOT_CONNECTED':
case 'SESSION_EXPIRED':
case 'REFRESH_EXHAUSTED':
case 'TOKEN_REVOKED':
case 'TOKEN_CORRUPTED':
case 'MISSING_SCOPE':
// All resolved the same way: disconnect + reconnect with BankID to mint a
// fresh token with the right scope.
return { code: 'SKATTEVERKET_NOT_CONNECTED', httpStatus: 401 }
case 'BEHORIGHET_SAKNAS':
case 'ACCESS_DENIED':
return { code: 'SKATTEVERKET_ACCESS_DENIED', httpStatus: 403 }
case 'RATE_LIMITED':
return { code: 'SKATTEVERKET_RATE_LIMITED', httpStatus: 429 }
}
}
+14 -1
View File
@@ -26,7 +26,8 @@ export const API_KEY_SCOPES = {
'operations:read': { label: 'Operationer — läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
'documents:read': { label: 'Dokument — läs', description: 'Lista och hämta dokumentbilagor' },
'documents:write': { label: 'Dokument — skriv', description: 'Ladda upp och koppla dokument till verifikationer' },
'compliance:read': { label: 'Compliance — läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet' },
'compliance:read': { label: 'Compliance — läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; Skatteverket-status (moms + AGI)' },
'skatteverket:write': { label: 'Skatteverket — skriv', description: 'Lämna momsdeklaration och arbetsgivardeklaration (AGI) till Skatteverket (stagas; signeras med BankID)' },
'agent:read': { label: 'Agent — läs', description: 'Specialiserad bokföringsassistent: profil, laddade specialister/atomer, minnen (briefing + skill-katalog)' },
'agent:write': { label: 'Agent — skriv', description: 'Spara och ta bort agentens minnen om företaget (remember_fact, forget_fact)' },
'pending_operations:read': { label: 'Stagade operationer — läs', description: 'Lista pending_operations (staged writes awaiting approval)' },
@@ -113,6 +114,10 @@ export const STAGING_SCOPES: ApiKeyScope[] = [
'bookkeeping:write',
'payroll:write',
'documents:write',
// Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a
// key holding both this and pending_operations:approve is a SoD conflict —
// findStageApproveConflict picks it up automatically from this list.
'skatteverket:write',
]
/**
@@ -142,6 +147,7 @@ export const SCOPE_GROUPS = [
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
{ domain: 'pending_operations', label: 'Stagade operationer', read: 'pending_operations:read' as const, write: 'pending_operations:approve' as const },
{ domain: 'agent', label: 'Agent', read: 'agent:read' as const, write: 'agent:write' as const },
{ domain: 'skatteverket', label: 'Skatteverket', read: null, write: 'skatteverket:write' as const },
] as const
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
@@ -247,6 +253,13 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_list_pending_operations: 'pending_operations:read',
gnubok_approve_pending_operation: 'pending_operations:approve',
gnubok_reject_pending_operation: 'pending_operations:approve',
// Skatteverket filing (PR5). Reads are compliance:read (status of moms/AGI);
// the two submit tools require the opt-in skatteverket:write staging scope.
gnubok_vat_declaration_validate: 'compliance:read',
gnubok_vat_declaration_status: 'compliance:read',
gnubok_agi_status: 'compliance:read',
gnubok_vat_declaration_submit: 'skatteverket:write',
gnubok_agi_submit: 'skatteverket:write',
}
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
+40
View File
@@ -2199,6 +2199,45 @@ const BULK_BOOK: Record<string, StructuredErrorEntry> = {
},
}
// ─────────────────────────────────────────────────────────────────
// Skatteverket filing codes (PR5 — MCP momsdeklaration + AGI tools)
// ─────────────────────────────────────────────────────────────────
const SKATTEVERKET: Record<string, StructuredErrorEntry> = {
EXTENSION_DISABLED: {
httpStatus: 503,
message_sv: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
message_en: 'The Skatteverket integration is not enabled in this environment.',
},
SKATTEVERKET_NOT_CONNECTED: {
httpStatus: 401,
message_sv:
'Anslutningen till Skatteverket saknas eller har gått ut. Anslut med BankID under Inställningar → Skatteverket.',
message_en: 'No valid Skatteverket connection. Reconnect with BankID before retrying.',
remediation: {
description:
'Connect (or reconnect) to Skatteverket with BankID under Settings → Skatteverket, then retry.',
},
},
SKATTEVERKET_ACCESS_DENIED: {
httpStatus: 403,
message_sv:
'Behörighet saknas hos Skatteverket för det här företaget. Kontrollera att du är firmatecknare eller deklarationsombud.',
message_en:
'Skatteverket denied access for this company (missing authorisation or scope).',
remediation: {
description:
'Verify the signed-in user is firmatecknare/deklarationsombud for this company at Skatteverket, then reconnect with BankID.',
},
},
SKATTEVERKET_RATE_LIMITED: {
httpStatus: 429,
message_sv: 'För många förfrågningar mot Skatteverket. Vänta en stund och försök igen.',
message_en: 'Skatteverket rate limit exceeded.',
retryable: true,
},
}
// ─────────────────────────────────────────────────────────────────
// Combined registry
// ─────────────────────────────────────────────────────────────────
@@ -2238,6 +2277,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...COMPANY,
...API_KEY,
...PROVIDER,
...SKATTEVERKET,
}
export function getErrorEntry(code: string): StructuredErrorEntry | undefined {
@@ -0,0 +1,162 @@
/**
* Unit tests for commitSubmitVatDeclaration / commitSubmitAgi.
* Driven through the public commitPendingOperation dispatcher.
*
* The MCP submit tools stage submit_vat_declaration / submit_agi ops; this
* dispatcher resolves the skatteverket extension's commit services via the
* registry and translates their SkvSubmitResult into the op lifecycle:
* - ok → committed (signing_url in result_data)
* - recoverable failure → released back to 'pending' (re-approve works)
* - non-recoverable / SKV business error → rejected
*
* A FAKE extension is registered in the registry so no real SKV/extension
* code runs — this isolates the core wiring (registry resolution + lifecycle).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { extensionRegistry } from '@/lib/extensions/registry'
import type { Extension } from '@/lib/extensions/types'
import type { SkvSubmitResult } from '@/lib/pending-operations/skatteverket-commit'
import type { PendingOperation } from '@/types'
import { commitPendingOperation } from '../commit'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'submit_vat_declaration',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'high',
created_at: '2026-06-01T00:00:00Z',
resolved_at: null,
updated_at: '2026-06-01T00:00:00Z',
...overrides,
} as PendingOperation
}
function registerFakeSkatteverket(
services: Record<string, (...a: unknown[]) => Promise<SkvSubmitResult>>,
): void {
extensionRegistry.register({
id: 'skatteverket',
name: 'fake-skatteverket',
version: '0.0.0',
services,
} as unknown as Extension)
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
afterEach(() => {
extensionRegistry.clear()
})
describe('commitPendingOperation: submit_vat_declaration / submit_agi', () => {
it('happy VAT path → committed with signing_url + awaiting_signature status', async () => {
const vat = vi.fn().mockResolvedValue({
ok: true, signing_url: 'https://skv.test/sign/abc', redovisningsperiod: '202503',
})
registerFakeSkatteverket({ commitSubmitVatDeclaration: vat, commitSubmitAgi: vi.fn() })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher commit update
const op = makePendingOp({ params: { period_type: 'monthly', year: 2025, period: 3 } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ signing_url: 'https://skv.test/sign/abc', status: 'awaiting_signature' })
expect(vat).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', {
period_type: 'monthly', year: 2025, period: 3,
})
})
it('happy AGI path → committed with signing_url', async () => {
const agi = vi.fn().mockResolvedValue({ ok: true, signing_url: 'https://skv.test/agi/xyz', period: '202503' })
registerFakeSkatteverket({ commitSubmitVatDeclaration: vi.fn(), commitSubmitAgi: agi })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null })
const op = makePendingOp({ operation_type: 'submit_agi', params: { salary_run_id: 'sr-1' } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ signing_url: 'https://skv.test/agi/xyz' })
expect(agi).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', { salary_run_id: 'sr-1' })
})
it('no service registered → failed EXTENSION_DISABLED, op released to pending', async () => {
// registry is empty (afterEach cleared it; nothing registered here)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // release-to-pending update
const op = makePendingOp({ params: { period_type: 'monthly', year: 2025, period: 3 } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.code).toBe('EXTENSION_DISABLED')
expect(result.http_status).toBe(503)
})
it('recoverable service result → released to pending with the structured code', async () => {
const vat = vi.fn().mockResolvedValue({
ok: false, code: 'SKATTEVERKET_NOT_CONNECTED', http_status: 401, recoverable: true, error: 'no connection',
})
registerFakeSkatteverket({ commitSubmitVatDeclaration: vat, commitSubmitAgi: vi.fn() })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null }) // release-to-pending update
const op = makePendingOp({ params: { period_type: 'monthly', year: 2025, period: 3 } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.code).toBe('SKATTEVERKET_NOT_CONNECTED')
expect(result.http_status).toBe(401)
})
it('non-recoverable service result → op rejected (consumed)', async () => {
const vat = vi.fn().mockResolvedValue({
ok: false, code: 'SKATTEVERKET_SUBMIT_REJECTED', http_status: 400, recoverable: false, error: 'SKV rejected the draft',
})
registerFakeSkatteverket({ commitSubmitVatDeclaration: vat, commitSubmitAgi: vi.fn() })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null }) // reject update
const op = makePendingOp({ params: { period_type: 'monthly', year: 2025, period: 3 } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/rejected/i)
})
it('missing params → 400 without resolving the extension service', async () => {
const vat = vi.fn()
registerFakeSkatteverket({ commitSubmitVatDeclaration: vat, commitSubmitAgi: vi.fn() })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null }) // reject update
const op = makePendingOp({ params: { year: 2025 } }) // missing period_type + period
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(vat).not.toHaveBeenCalled()
})
})
+93
View File
@@ -50,6 +50,12 @@ import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
import { AccountsNotInChartError, isBookkeepingError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors'
import { extensionRegistry } from '@/lib/extensions/registry'
import {
SkatteverketRecoverableError,
type SkatteverketCommitServices,
type SkvSubmitResult,
} from '@/lib/pending-operations/skatteverket-commit'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -2833,6 +2839,71 @@ async function commitGenerateAgi(
}
}
// ── Skatteverket filing commit handlers (PR5) ─────────────────────
//
// Core cannot import @/extensions (CI guard), so these reach the Skatteverket
// extension only through the registry-resolved `services` channel. The service
// runs the SKV chain and returns a SkvSubmitResult (shared shape in
// ./skatteverket-commit). A recoverable failure throws SkatteverketRecoverable-
// Error, which the dispatcher catch releases back to 'pending'; a non-recoverable
// failure becomes a plain { error, status } that rejects the op.
function getSkatteverketServices(): SkatteverketCommitServices {
const services = extensionRegistry.get('skatteverket')?.services as
| Partial<SkatteverketCommitServices>
| undefined
if (!services?.commitSubmitVatDeclaration || !services?.commitSubmitAgi) {
// Extension absent or not wired. Recoverable — leave the op pending so a
// re-enable + re-approve works without re-staging.
throw new SkatteverketRecoverableError(
'Skatteverket-integrationen är inte tillgänglig.',
'EXTENSION_DISABLED',
503,
)
}
return services as SkatteverketCommitServices
}
function handleSkvSubmitResult(result: SkvSubmitResult): ExecutorResult {
if (!result.ok) {
if (result.recoverable) {
throw new SkatteverketRecoverableError(result.error, result.code, result.http_status)
}
return { error: result.error, status: result.http_status }
}
const data: Record<string, unknown> = { ...result, status: 'awaiting_signature' }
delete data.ok
return { data }
}
async function commitSubmitVatDeclaration(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
if (!params.period_type || !params.year || !params.period) {
return { error: 'period_type, year och period krävs', status: 400 }
}
const services = getSkatteverketServices()
const result = await services.commitSubmitVatDeclaration(supabase, userId, companyId, params)
return handleSkvSubmitResult(result)
}
async function commitSubmitAgi(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>,
): Promise<ExecutorResult> {
if (!params.salary_run_id) {
return { error: 'salary_run_id krävs', status: 400 }
}
const services = getSkatteverketServices()
const result = await services.commitSubmitAgi(supabase, userId, companyId, params)
return handleSkvSubmitResult(result)
}
// ── Multi-tx commit handlers (PRs #603/#606/#608/#610) ────────────
//
// Both wrap their SQL RPC. The RPCs do all the heavy lifting (locking,
@@ -3194,6 +3265,12 @@ async function commitPendingOperationInner(
case 'link_transaction_journal_entry':
result = await commitLinkTransactionJournalEntry(supabase, userId, companyId, pendingOp.params)
break
case 'submit_vat_declaration':
result = await commitSubmitVatDeclaration(supabase, userId, companyId, pendingOp.params)
break
case 'submit_agi':
result = await commitSubmitAgi(supabase, userId, companyId, pendingOp.params)
break
default:
return {
status: 'failed',
@@ -3220,6 +3297,22 @@ async function commitPendingOperationInner(
account_numbers: err.accountNumbers,
}
}
// Recoverable Skatteverket failure (extension disabled, no connection,
// rate-limited, still processing). Same contract as accounts-not-in-chart:
// release the claim back to 'pending' so the user can fix the connection/
// flag and re-approve the SAME op, and surface the structured code.
if (err instanceof SkatteverketRecoverableError) {
await supabase
.from('pending_operations')
.update({ status: 'pending' })
.eq('id', pendingOp.id)
return {
status: 'failed',
error: err.message,
http_status: err.httpStatus,
code: err.code,
}
}
const isBkErr = isBookkeepingError(err)
const message = err instanceof Error ? err.message : (isBkErr ? 'Bookkeeping error' : 'Executor failed')
// Release the claim by transitioning to 'rejected' so the row never gets
+7
View File
@@ -113,6 +113,13 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// invoice_payments row — sits next to link_invoice_voucher semantically;
// both attach an existing booking to a different entity.
link_transaction_journal_entry: 'medium',
// ── Skatteverket filing (PR5) ──────────────────────────────────────
// External + irreversible once signed. Commit sends the declaration for
// BankID signing; the user's signature in the browser is the filing act.
// (getRiskLevel already defaults unknown → 'high'; explicit for intent.)
submit_vat_declaration: 'high',
submit_agi: 'high',
}
export function getRiskLevel(operationType: string): RiskLevel {
@@ -0,0 +1,66 @@
/**
* Core ↔ Skatteverket-extension commit boundary.
*
* `lib/` cannot import from `@/extensions/` (CI guard, core-build.yml), so the
* commit-side executors reach the Skatteverket extension only through the
* registry-resolved `services` channel. This module defines the SHARED shape
* the extension's commit services return and the recoverable-error class the
* dispatcher uses to release an op back to `pending` — both live in core so the
* extension (which may import core freely) and `commit.ts` agree on the
* contract without core ever importing the extension.
*/
/** Result returned by the extension's commitSubmitVatDeclaration / commitSubmitAgi. */
export type SkvSubmitResult =
| ({
ok: true
/** BankID signing deep-link. The op is "sent for signing", not filed. */
signing_url: string
} & Record<string, unknown>)
| {
ok: false
/** Structured error code (see lib/errors/structured-errors.ts). */
code: string
http_status: number
/**
* true → the op is fine; the connection/flag/quota isn't. Release it back
* to `pending` so the user can fix and re-approve the SAME op.
* false → a wrong-data / SKV-business condition. Reject (consume) the op;
* the user must regenerate and re-stage.
*/
recoverable: boolean
error: string
}
/** The two functions a fully-wired skatteverket extension exposes on `services`. */
export interface SkatteverketCommitServices {
commitSubmitVatDeclaration: (
supabase: unknown,
userId: string,
companyId: string,
params: Record<string, unknown>,
) => Promise<SkvSubmitResult>
commitSubmitAgi: (
supabase: unknown,
userId: string,
companyId: string,
params: Record<string, unknown>,
) => Promise<SkvSubmitResult>
}
/**
* Thrown by a commit executor when the failure is recoverable (extension
* disabled, no SKV connection, rate-limited). The dispatcher catches it,
* releases the atomic claim back to `pending`, and surfaces { error, code,
* http_status } — mirroring the AccountsNotInChartError release path.
*/
export class SkatteverketRecoverableError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly httpStatus: number,
) {
super(message)
this.name = 'SkatteverketRecoverableError'
}
}
+3 -1
View File
@@ -1490,10 +1490,12 @@
"scope_events_read": "Read — poll event_log as a webhook fallback",
"scope_webhooks_manage": "Manage — create, list, update, delete subscriptions",
"scope_operations_read": "Read — status of long-running operations (import, year-end, revaluation)",
"scope_compliance_read": "Read — pre-flight: VAT closing, year-end readiness, voucher gaps, IB/UB continuity",
"scope_compliance_read": "Read — pre-flight: VAT closing, year-end readiness, voucher gaps, IB/UB continuity; Skatteverket status (VAT + AGI)",
"group_agent": "Agent",
"scope_agent_read": "Read — agent briefing: profile, loaded specialists, saved memories",
"scope_agent_write": "Write — save and remove the agent's memories about the company",
"group_skatteverket": "Skatteverket",
"scope_skatteverket_write": "Write — file VAT (momsdeklaration) and employer (AGI) declarations to Skatteverket (staged; BankID-signed)",
"sod_warning": "This key can both create bookkeeping and approve it. That lets an automated agent commit postings with no human review (segregation of duties).",
"sod_dialog_title": "Confirm combined permissions",
"sod_dialog_description": "This key combines a staging write scope with permission to approve staged operations. That lets an automated agent both create and approve postings without a human reviewing them. Create the key anyway?",
+3 -1
View File
@@ -1490,10 +1490,12 @@
"scope_events_read": "Läs — polla event_log som webhook-fallback",
"scope_webhooks_manage": "Hantera — skapa, lista, uppdatera, radera prenumerationer",
"scope_operations_read": "Läs — status för långkörande operationer (import, bokslut, omvärdering)",
"scope_compliance_read": "Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet",
"scope_compliance_read": "Läs — pre-flight: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; Skatteverket-status (moms + AGI)",
"group_agent": "Agent",
"scope_agent_read": "Läs — agentens briefing: profil, laddade specialister, sparade minnen",
"scope_agent_write": "Skriv — spara och ta bort agentens minnen om företaget",
"group_skatteverket": "Skatteverket",
"scope_skatteverket_write": "Skriv — lämna momsdeklaration och arbetsgivardeklaration (AGI) till Skatteverket (stagas; signeras med BankID)",
"sod_warning": "Den här nyckeln kan både skapa bokföring och godkänna den. Då kan en automatiserad agent committa verifikationer utan mänsklig granskning (ansvarsfördelning).",
"sod_dialog_title": "Bekräfta kombinerad behörighet",
"sod_dialog_description": "Nyckeln kombinerar ett skriv-scope som stagar bokföring med behörighet att godkänna stagade operationer. Då kan en automatiserad agent både skapa och godkänna verifikationer utan att en människa granskar dem. Skapa nyckeln ändå?",
@@ -0,0 +1,66 @@
-- Backfill `submit_vat_declaration` and `submit_agi` into the
-- pending_operations.operation_type CHECK constraint.
--
-- PR5 of the agent-first P0 set wraps the existing Skatteverket extension as
-- five MCP tools. The two high-risk submit tools (gnubok_vat_declaration_submit
-- and gnubok_agi_submit) stage a pending operation that, on approval, dispatches
-- into the skatteverket extension's commit services (commitSubmitVatDeclaration /
-- commitSubmitAgi) and returns a BankID signing link. "Commit" here means
-- "send for signing", not "file" — the irreversible act is the user's BankID
-- signature in the browser. Both ops carry a risk-tier entry ('high', external
-- and irreversible once signed).
--
-- Without this migration any INSERT staged by the new submit tools would be
-- rejected with a constraint violation before the commit-side code ever runs,
-- blocking the staged-operation review flow.
--
-- pg-test: covered-by — this is a CHECK-list expansion only (no trigger/RPC/
-- RLS/DEFERRABLE change), so no *.pg.test.ts is required.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration', -- Skatteverket momsdeklaration → BankID signing link
'submit_agi' -- Skatteverket arbetsgivardeklaration → BankID signing link
));
NOTIFY pgrst, 'reload schema';
+7 -4
View File
@@ -12,7 +12,7 @@ import { getPool } from '@/tests/pg/setup'
*
* - Correctness: uuid_generate_v4() produces valid non-null UUIDs with
* distinct values across calls. gen_random_bytes(n) returns a bytea of
* exactly n bytes; gen_random_bytes(0) returns empty bytea.
* exactly n bytes (n >= 1; OpenSSL-backed pgcrypto rejects size 0).
*
* - Function properties: VOLATILE, PARALLEL SAFE, SET search_path = '',
* LANGUAGE sql — consistent with the rest of the codebase.
@@ -80,12 +80,15 @@ describe('extension function wrappers', () => {
}
})
it('gen_random_bytes(0) returns empty bytea', async () => {
it('gen_random_bytes(n) returns exactly n bytes', async () => {
// Modern (OpenSSL-backed) pgcrypto rejects size 0 with "Length not in
// range", so we assert the real contract on a positive length rather than
// the version-dependent 0-byte edge case.
const pool = getPool()
const r = await pool.query<{ len: number }>(
`SELECT octet_length(public.gen_random_bytes(0)) AS len`,
`SELECT octet_length(public.gen_random_bytes(16)) AS len`,
)
expect(r.rows[0]!.len).toBe(0)
expect(r.rows[0]!.len).toBe(16)
})
it('gen_random_bytes returns distinct values across calls', async () => {
+4
View File
@@ -1607,6 +1607,10 @@ export type PendingOperationType =
| 'bulk_book_transactions'
// PR #614: link a single bank tx to an already-posted verifikat (no new JE)
| 'link_transaction_journal_entry'
// PR5: Skatteverket filing via MCP. Commit = "send for BankID signing"
// (returns a signing link); the user's signature in the browser files it.
| 'submit_vat_declaration'
| 'submit_agi'
export type PendingOperationStatus = 'pending' | 'committing' | 'committed' | 'rejected'
export type PendingOperationActorType = 'user' | 'api_key' | 'mcp_oauth' | 'cron'