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