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,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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user