From 679b154ad2aac6033850806eacab2f4d6fa2361f Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:43:15 +0200 Subject: [PATCH] feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5) (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * ci(pr-agent): use Claude Opus 4.8 (Sonnet 4.6 fallback) Co-Authored-By: Claude Opus 4.8 (1M context) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/pr-agent.yml | 79 +++ components/settings/ApiKeysPanel.tsx | 8 +- .../__tests__/skatteverket-tools.test.ts | 197 ++++++++ extensions/general/mcp-server/server.ts | 384 ++++++++++++++- .../__tests__/commit-services.test.ts | 94 ++++ .../__tests__/declaration-prep.test.ts | 110 +++++ .../skatteverket/__tests__/error-map.test.ts | 30 ++ extensions/general/skatteverket/index.ts | 452 ++++++++++++------ extensions/general/skatteverket/lib/audit.ts | 59 +++ .../skatteverket/lib/declaration-prep.ts | 152 ++++++ .../general/skatteverket/lib/error-map.ts | 41 ++ lib/auth/api-keys.ts | 15 +- lib/errors/structured-errors.ts | 40 ++ .../__tests__/skatteverket-executors.test.ts | 162 +++++++ lib/pending-operations/commit.ts | 93 ++++ lib/pending-operations/risk-tiers.ts | 7 + lib/pending-operations/skatteverket-commit.ts | 66 +++ messages/en.json | 4 +- messages/sv.json | 4 +- ...ing_operations_add_skatteverket_submit.sql | 66 +++ tests/pg/extension-wrappers.pg.test.ts | 11 +- types/index.ts | 4 + 22 files changed, 1913 insertions(+), 165 deletions(-) create mode 100644 .github/workflows/pr-agent.yml create mode 100644 extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts create mode 100644 extensions/general/skatteverket/__tests__/commit-services.test.ts create mode 100644 extensions/general/skatteverket/__tests__/declaration-prep.test.ts create mode 100644 extensions/general/skatteverket/__tests__/error-map.test.ts create mode 100644 extensions/general/skatteverket/lib/audit.ts create mode 100644 extensions/general/skatteverket/lib/declaration-prep.ts create mode 100644 extensions/general/skatteverket/lib/error-map.ts create mode 100644 lib/pending-operations/__tests__/skatteverket-executors.test.ts create mode 100644 lib/pending-operations/skatteverket-commit.ts create mode 100644 supabase/migrations/20260620120000_pending_operations_add_skatteverket_submit.sql diff --git a/.github/workflows/pr-agent.yml b/.github/workflows/pr-agent.yml new file mode 100644 index 00000000..dcfcbdea --- /dev/null +++ b/.github/workflows/pr-agent.yml @@ -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" diff --git a/components/settings/ApiKeysPanel.tsx b/components/settings/ApiKeysPanel.tsx index 0dd4cff6..a2805162 100644 --- a/components/settings/ApiKeysPanel.tsx +++ b/components/settings/ApiKeysPanel.tsx @@ -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 diff --git a/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts b/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts new file mode 100644 index 00000000..8210b935 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts @@ -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 + 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_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() + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 9ca97a82..c904f4ce 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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) ── { diff --git a/extensions/general/skatteverket/__tests__/commit-services.test.ts b/extensions/general/skatteverket/__tests__/commit-services.test.ts new file mode 100644 index 00000000..1d32c83e --- /dev/null +++ b/extensions/general/skatteverket/__tests__/commit-services.test.ts @@ -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 + return { ...actual, skvRequest: (...a: unknown[]) => mockSkvRequest(...a) } +}) + +const mockBuildMomsuppgift = vi.fn() +vi.mock('../lib/declaration-prep', async (importOriginal) => { + const actual = (await importOriginal()) as Record + 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, +) => 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 }) + }) +}) diff --git a/extensions/general/skatteverket/__tests__/declaration-prep.test.ts b/extensions/general/skatteverket/__tests__/declaration-prep.test.ts new file mode 100644 index 00000000..37c49bae --- /dev/null +++ b/extensions/general/skatteverket/__tests__/declaration-prep.test.ts @@ -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: '', 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: '', + 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/) + }) +}) diff --git a/extensions/general/skatteverket/__tests__/error-map.test.ts b/extensions/general/skatteverket/__tests__/error-map.test.ts new file mode 100644 index 00000000..000a6924 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/error-map.test.ts @@ -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() + } + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 703d018c..ec75250f 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -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 { - 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 -}> { +): Promise { 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 { 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 => new Promise((resolve) => setTimeout(resolve, ms)) + +const EXTENSION_DISABLED_RESULT: Extract = { + 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> { + 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, +): Promise { + 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, +): Promise { + 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) + } +} diff --git a/extensions/general/skatteverket/lib/audit.ts b/extensions/general/skatteverket/lib/audit.ts new file mode 100644 index 00000000..b89b2a52 --- /dev/null +++ b/extensions/general/skatteverket/lib/audit.ts @@ -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 { + 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), + }) + } +} diff --git a/extensions/general/skatteverket/lib/declaration-prep.ts b/extensions/general/skatteverket/lib/declaration-prep.ts new file mode 100644 index 00000000..d925fedb --- /dev/null +++ b/extensions/general/skatteverket/lib/declaration-prep.ts @@ -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 { + 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 { + 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 { + 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, + } +} diff --git a/extensions/general/skatteverket/lib/error-map.ts b/extensions/general/skatteverket/lib/error-map.ts new file mode 100644 index 00000000..9fbc5e13 --- /dev/null +++ b/extensions/general/skatteverket/lib/error-map.ts @@ -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 } + } +} diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 11675595..6f73f29c 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -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 = { 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 { diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index e8fb3f4e..13ac72b8 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2199,6 +2199,45 @@ const BULK_BOOK: Record = { }, } +// ───────────────────────────────────────────────────────────────── +// Skatteverket filing codes (PR5 — MCP momsdeklaration + AGI tools) +// ───────────────────────────────────────────────────────────────── + +const SKATTEVERKET: Record = { + 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 = { ...COMPANY, ...API_KEY, ...PROVIDER, + ...SKATTEVERKET, } export function getErrorEntry(code: string): StructuredErrorEntry | undefined { diff --git a/lib/pending-operations/__tests__/skatteverket-executors.test.ts b/lib/pending-operations/__tests__/skatteverket-executors.test.ts new file mode 100644 index 00000000..c3f9fe2b --- /dev/null +++ b/lib/pending-operations/__tests__/skatteverket-executors.test.ts @@ -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 { + 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 Promise>, +): 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() + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 28475814..4339407e 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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 + | 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 = { ...result, status: 'awaiting_signature' } + delete data.ok + return { data } +} + +async function commitSubmitVatDeclaration( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record, +): Promise { + 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, +): Promise { + 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 diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index 97fdd182..66ecd7c7 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -113,6 +113,13 @@ export const OPERATION_RISK_TIERS: Record = { // 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 { diff --git a/lib/pending-operations/skatteverket-commit.ts b/lib/pending-operations/skatteverket-commit.ts new file mode 100644 index 00000000..05f0dd2e --- /dev/null +++ b/lib/pending-operations/skatteverket-commit.ts @@ -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) + | { + 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, + ) => Promise + commitSubmitAgi: ( + supabase: unknown, + userId: string, + companyId: string, + params: Record, + ) => Promise +} + +/** + * 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' + } +} diff --git a/messages/en.json b/messages/en.json index 46170abc..e7ab902b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/sv.json b/messages/sv.json index 546060e3..6fabfee7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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å?", diff --git a/supabase/migrations/20260620120000_pending_operations_add_skatteverket_submit.sql b/supabase/migrations/20260620120000_pending_operations_add_skatteverket_submit.sql new file mode 100644 index 00000000..40cb69a5 --- /dev/null +++ b/supabase/migrations/20260620120000_pending_operations_add_skatteverket_submit.sql @@ -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'; diff --git a/tests/pg/extension-wrappers.pg.test.ts b/tests/pg/extension-wrappers.pg.test.ts index 779ded5e..ea972b3c 100644 --- a/tests/pg/extension-wrappers.pg.test.ts +++ b/tests/pg/extension-wrappers.pg.test.ts @@ -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 () => { diff --git a/types/index.ts b/types/index.ts index 6857ce0a..bea8bde8 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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'