feat(skatteverket): expose filed VAT declarations and decisions via the v1 API (#1773)

* feat(skatteverket): expose filed VAT declarations and decisions via the v1 API

Add GET /api/v1/companies/:companyId/skatteverket/vat-declarations, returning
a period's momsdeklaration as Skatteverket has it on file: the submitted
declaration (SKV /inlamnat) and Skatteverket's beslut (SKV /beslutat), either
individually via ?state= or both.

- Auth: compliance:read scope; member-visibility read model per #1673
  (resolveReadAuth: caller's token, any member's active token, or system
  credentials with a verified ombud grant).
- Architecture: core reaches the Skatteverket extension through the
  registry-resolved services channel (contract in
  lib/skatteverket/declaration-status.ts), so core never imports from
  @/extensions/.
- New structured error SKATTEVERKET_API_ERROR (502) for upstream SKV
  failures; 404 from SKV maps to submitted/decided = null with HTTP 200.
- 19 new tests (route: auth, validation, extension-disabled, happy path;
  extension service: auth resolution, state filtering, SKV error mapping).

Fixes #1663

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): address review findings on the vat-declarations read API

Consolidated fixes for PR #1773 review round:

- apiskill sync (core-build Checks): map the new skatteverket endpoint
  group into the periods.md reference and regenerate skills/accounted-api
  (124 -> 125 operations).
- CodeRabbit: parse the SKV 2xx body before writing the audit row, so an
  unreadable body is audited as skv_error and returns the structured
  SKATTEVERKET_API_ERROR 502 instead of escaping as an internal 500;
  regression test added.
- Compliance swarm (ISO A.8.12 / SOC2 CC6.1): stop forwarding the raw
  upstream SKV response body to API consumers; the caller now gets the
  status code and a generic Swedish message, the body is logged
  server-side only.
- Compliance swarm (GDPR Art.30): add the moms.declaration_status_read
  processing activity to .compliance/ropa.yaml (live read, no payload
  persisted, audit-log metadata only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-21 12:12:18 +02:00
committed by GitHub
parent 0de766c6a4
commit 60920ec794
15 changed files with 949 additions and 7 deletions
+41
View File
@@ -228,6 +228,47 @@ processing_activities:
- bankid_signing_required_for_filing
- immutable_audit_log
- id: moms.declaration_status_read
name: Avläsning av inlämnad momsdeklaration och beslut från Skatteverket
purpose: >-
Läsa tillbaka en redan inlämnad momsdeklaration (inlamnat) och
Skatteverkets beslut (beslutat) för en period, på begäran av en
medlem i företaget via v1 REST-API:t eller MCP, så att bokföringen
kan stämmas av mot faktiskt inlämnade uppgifter. Läsning sker live
mot Skatteverket; deklarationsinnehållet lagras inte hos oss, endast
en audit-rad om att anropet gjordes.
lawful_basis: art_6_1_b # service provision: read-back requested by the customer
special_category_basis: null
controller: gnubok-tenant
processor: anthropic-na
data_subjects:
- business_owner
data_categories:
- user.government_id # orgnummer (juridiska personer) eller pnr
- user.financial.tax # rutor 05-62 i det inlämnade underlaget/beslutet
recipients:
- name: Skatteverket
country: SE
role: legal_recipient
international_transfers:
applicable: false
mechanism: null
note: Sweden-to-Sweden flow; no third-country transfer.
retention:
# Response bodies are returned to the caller and never persisted.
# Only the outbound call metadata lands in the audit log.
duration: 7y
basis: bfl_7_kap
stored_in:
- skatteverket_api_audit_log
security_measures:
- rls_company_scoped
- rbac_company_member_role_check
- tls_1_3_to_skatteverket
- immutable_audit_log
- no_payload_body_persisted
- upstream_error_bodies_logged_server_side_only
- id: psd2.cash_account_mirror
name: PSD2-konton speglas till cash_accounts
purpose: >-
+1
View File
@@ -1126,6 +1126,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-20] Strict JSON on OpenAI-compatible endpoints uses a hand-maintained JSON-schema mirror of the extraction Zod schema, opt-in via AI_STRICT_JSON, never an automatic Zod-to-JSON-schema conversion: the Zod schema carries .catch()/.transform() that have no schema equivalent and a generated schema would drift silently. Zod stays the validator either way; JSON-in-prose + extractJsonObject remains the default everywhere because it works on every model and is what hosted runs.
[2026-08-20] AI_API_KEY made optional for the OpenAI-compatible backend: a base URL alone now counts as configured (hasAiCredentials / resolveAiProvider), so a local model server (llama.cpp/Ollama/LM Studio/vLLM), which usually has no auth, works with just AI_BASE_URL + AI_MODEL. The openai-compatible service only sends an Authorization: Bearer when AI_API_KEY is set, so a keyless local server is never handed an empty bearer. Hosted providers that require a key still set AI_API_KEY. Bedrock/Anthropic credential logic unchanged.
[2026-08-20] poppler-utils is the one system package added to the self-host runner image (Sovereign plan WS1 PR2): pdftoppm renders the first pages of a PDF for AI backends without native PDF input (an OpenAI-compatible Swedish endpoint), measured at ~4 MB plus shared libs on node:22-alpine (pdftoppm 25.12), written to /tmp which docker-compose.yml already mounts as tmpfs under the read-only root. Hosted never calls it (Bedrock reads PDFs natively) and the cron image is untouched. pdfjs-dist + @napi-rs/canvas were rejected earlier (two npm deps, memory spikes, dead weight on hosted). scripts/smoke-ai-provider.ts is the backend-agnostic "is AI wired up" check; verified live against hosted Bedrock and against a local OpenAI-compatible mock (the mock received Bearer auth, per-tier model ids and one image_url part per rasterized page).
[2026-08-20] #1663 filed-declaration read API (v1 GET /skatteverket/vat-declarations) reaches the Skatteverket extension through a registry-resolved read service (lib/skatteverket/declaration-status.ts, mirroring the skatteverket-commit boundary), not by importing extension code into core (CI guard forbids it) and not by dispatching to the cookie-session extension routes (wrong auth surface, deadline-completion side effects): the service uses resolveReadAuth so any company member's API key can read what another member connected (#1673 model); the existing MCP gnubok_vat_declaration_status stays on caller-token auth (changing it is a separate concern); AGI got no v1 endpoint because gnubok_agi_status covers MCP reads and the /agi/kvittenser read path carries reconciliation writes that need their own design.
[2026-08-20] RIP-3 chat cutover is scoped to general.help only: the free-form Q&A /chat panel now runs on a page-scoped single-call console (AskConsole → POST /api/agent/ask, persist:true), so it works on any configured backend incl. a local OpenAI-compatible model. The tool-loop intents (transaction.categorization, invoice.draft, supplier_invoice.review) and the docked AgentSheet still use AgentChat + run-turn.ts because they stage operations and need the tool loop, so run-turn.ts is NOT deleted here (the plan gates its deletion on "once nothing calls them"; RIP-4 migrates the rest). Persistence is an opt-in branch on the existing /api/agent/ask route rather than a new endpoint, so page-scoped one-off asks (a report page) stay stateless; the console writes both turns to agent_conversations/agent_messages as canonical Anthropic text blocks so the /chat sidebar and resume keep working across old streaming threads and new single-call ones.
[2026-08-19] Provider re-sync replace mode resolves EVERY overlapping completed sie_imports row and treats one it cannot resolve (not_found/not_completed) as a stale watermark to skip, importing the year fresh, but still aborts on a locked or closed period: an unresolvable row has nothing left to delete, while importing over entries that could not be deleted would duplicate verifikationer.
[2026-08-20] The single-call /chat assistant answers over a bounded READ-ONLY tool loop (audit Option A: "single-call actions over the existing MCP tool functions"), plus an always-on company snapshot as the backstop: #1759 shipped a version that read only the company name/entity, so it answered "jag har ingen bokföringsdata" to every figures question. Rather than re-introduce the ripped streaming Anthropic runtime, the provider-agnostic lib/ai generateText gained optional `tools`/`maxSteps`: the OpenAI-compatible service forwards them to the Vercel AI SDK (stopWhen: stepCountIs) which drives the loop, and the Anthropic-family service hand-rolls a small loop against messages.create (kept on the raw Anthropic SDK so no new deps and hosted stays byte-identical for every non-tool caller). ask-service attaches ONLY the read slice of general.help's tool whitelist via agentToolRegistry (write/staging + memory-write tools excluded; readOnlyHint/destructiveHint re-checked), dispatched with the same agent_chat actor run-turn uses. Works on Bedrock and on any local model with function-calling (Qwen); a text-only model still answers status questions from the snapshot (company_settings + deadlines, never figures). Not chosen: deterministic-context-only (bounded coverage) and unifying both providers on the AI SDK (would need @ai-sdk/anthropic + @ai-sdk/amazon-bedrock deps and change the hosted path).
@@ -0,0 +1,194 @@
/**
* Tests for GET /api/v1/companies/:companyId/skatteverket/vat-declarations
* (issue #1663): auth 401, scope 403, validation 400, extension-absent 503,
* structured error passthrough, and the happy path via the registry-resolved
* read service.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: { get: vi.fn() },
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { extensionRegistry } from '@/lib/extensions/registry'
import { GET } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockRegistryGet = extensionRegistry.get as ReturnType<typeof vi.fn>
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const BASE = `https://x.test/api/v1/companies/${COMPANY_ID}/skatteverket/vat-declarations`
type MockResult = { data?: unknown; error?: unknown }
function makeSupabase(byTable: Record<string, MockResult>) {
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve(byTable[table] ?? { data: null, error: null })
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
function makeRequest(url: string, withAuth = true): Request {
return new Request(url, {
method: 'GET',
headers: withAuth ? { Authorization: 'Bearer test-fixture-not-a-real-key' } : {},
})
}
const params = { params: Promise.resolve({ companyId: COMPANY_ID }) }
const mockFetchStatus = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
scopes: ['compliance:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(
makeSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
mockRegistryGet.mockReturnValue({
id: 'skatteverket',
services: { fetchVatDeclarationStatus: mockFetchStatus },
})
mockFetchStatus.mockResolvedValue({
ok: true,
redovisare: '165560000167',
redovisningsperiod: '202603',
submitted: { skatt: 12500 },
decided: null,
})
})
describe('GET /api/v1/companies/:companyId/skatteverket/vat-declarations', () => {
it('returns 401 without a bearer token', async () => {
const res = await GET(
makeRequest(`${BASE}?period_type=quarterly&year=2026&period=1`, false),
params,
)
expect(res.status).toBe(401)
expect(mockFetchStatus).not.toHaveBeenCalled()
})
it('rejects keys without compliance:read scope', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
scopes: ['reports:read'],
mode: 'live',
})
const res = await GET(
makeRequest(`${BASE}?period_type=quarterly&year=2026&period=1`),
params,
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
})
it('returns 404 when the key user is not a member of the company', async () => {
mockServiceClient.mockReturnValue(
makeSupabase({ company_members: { data: null, error: null } }),
)
const res = await GET(
makeRequest(`${BASE}?period_type=quarterly&year=2026&period=1`),
params,
)
expect(res.status).toBe(404)
})
it('rejects a missing period_type with 400', async () => {
const res = await GET(makeRequest(`${BASE}?year=2026&period=1`), params)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mockFetchStatus).not.toHaveBeenCalled()
})
it('rejects a quarterly period out of range with 400', async () => {
const res = await GET(
makeRequest(`${BASE}?period_type=quarterly&year=2026&period=5`),
params,
)
expect(res.status).toBe(400)
expect(mockFetchStatus).not.toHaveBeenCalled()
})
it('returns 503 EXTENSION_DISABLED when the extension is not registered', async () => {
mockRegistryGet.mockReturnValue(undefined)
const res = await GET(
makeRequest(`${BASE}?period_type=quarterly&year=2026&period=1`),
params,
)
expect(res.status).toBe(503)
const body = await res.json()
expect(body.error.code).toBe('EXTENSION_DISABLED')
})
it('passes structured service failures through (SKATTEVERKET_NOT_CONNECTED → 401)', async () => {
mockFetchStatus.mockResolvedValue({
ok: false,
code: 'SKATTEVERKET_NOT_CONNECTED',
http_status: 401,
error: 'Inte ansluten till Skatteverket.',
})
const res = await GET(
makeRequest(`${BASE}?period_type=quarterly&year=2026&period=1`),
params,
)
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error.code).toBe('SKATTEVERKET_NOT_CONNECTED')
expect(body.error.details).toMatchObject({ message: 'Inte ansluten till Skatteverket.' })
})
it('happy path: forwards the parsed period and returns the envelope', async () => {
const res = await GET(
makeRequest(`${BASE}?period_type=quarterly&year=2026&period=1&state=submitted`),
params,
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toEqual({
redovisare: '165560000167',
redovisningsperiod: '202603',
submitted: { skatt: 12500 },
decided: null,
})
expect(body.meta.request_id).toMatch(/^req_/)
// The service gets the caller's identity + the URL company, coerced params.
expect(mockFetchStatus).toHaveBeenCalledWith(
expect.anything(),
'user-1',
COMPANY_ID,
{ periodType: 'quarterly', year: 2026, period: 1, state: 'submitted' },
)
})
})
@@ -0,0 +1,165 @@
/**
* GET /api/v1/companies/{companyId}/skatteverket/vat-declarations
*
* Read a period's momsdeklaration as Skatteverket has it on file: the
* submitted declaration (inlamnat) and/or Skatteverket's beslut (beslutat).
* Issue #1663: lets integrators compare their books against actually-filed
* data (e.g. year-over-year sanity checks), which Skatteverket's own portal
* blocks from automation.
*
* Core cannot import from `@/extensions/` (CI guard), so the Skatteverket
* call goes through the registry-resolved `services` channel declared in
* lib/skatteverket/declaration-status.ts: same pattern the pending-operations
* dispatcher uses for the SKV commit services.
*/
import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { extensionRegistry } from '@/lib/extensions/registry'
import type { SkatteverketReadServices } from '@/lib/skatteverket/declaration-status'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
ensureInitialized()
const Query = z
.object({
period_type: z.enum(['monthly', 'quarterly', 'yearly']),
year: z.coerce.number().int().min(2000).max(2100),
period: z.coerce.number().int().min(1).max(12),
state: z.enum(['submitted', 'decided', 'both']).optional(),
})
.superRefine((val, issueCtx) => {
const max = val.period_type === 'monthly' ? 12 : val.period_type === 'quarterly' ? 4 : 1
if (val.period > max) {
issueCtx.addIssue({
code: 'custom',
path: ['period'],
message: `period must be 1-${max} for period_type=${val.period_type}`,
})
}
})
const VatDeclarationStatus = z.object({
redovisare: z.string(),
redovisningsperiod: z.string(),
submitted: z.unknown().nullable(),
decided: z.unknown().nullable(),
})
const VatDeclarationStatusResponse = dataEnvelope(VatDeclarationStatus)
registerEndpoint({
operation: 'skatteverket.vat_declarations.get',
method: 'GET',
path: '/api/v1/companies/:companyId/skatteverket/vat-declarations',
summary: 'Read a filed momsdeklaration (submitted and/or decided) from Skatteverket.',
description:
'Fetches the momsdeklaration for one period as Skatteverket has it on file: `submitted` is the declaration as filed (SKV /inlamnat), `decided` is Skatteverket\'s beslut (SKV /beslutat). Either section is null when nothing is on file for the period (or when excluded via ?state=). Query params: period_type (monthly|quarterly|yearly), year, period (1-12 monthly, 1-4 quarterly, 1 yearly), optional state (submitted|decided|both, default both). Requires the company to have an active Skatteverket connection (any member\'s BankID connection, or a verified ombud grant). Live read against Skatteverket, not a cached copy.',
useWhen:
'You want to verify what was actually filed for a VAT period, compare a period against last year\'s filed declaration, or check whether Skatteverket has decided a period.',
doNotUseFor:
'Computing the declaration from the books (use the VAT report), or filing: submission is a separate BankID-signed flow.',
pitfalls: [
'This is a live Skatteverket read: it fails with SKATTEVERKET_NOT_CONNECTED (401) until someone in the company has connected with BankID under Installningar, and the response reflects SKV\'s state, not the books.',
'submitted=null and decided=null with HTTP 200 means "nothing on file for the period": it is not an error.',
'A submitted declaration can lack a beslut for days: poll decided separately rather than assuming both appear together.',
'redovisningsperiod is SKV\'s YYYYMM format (the period\'s LAST month): quarterly period 1 is 03, not 01.',
],
example: {
request: { period_type: 'quarterly', year: 2026, period: 1 },
response: {
data: {
redovisare: '165560000167',
redovisningsperiod: '202603',
submitted: { mervardesskattTillfalle: '2026-04-10' },
decided: null,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'compliance:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
request: { query: Query },
response: {
success: VatDeclarationStatusResponse,
errorCodes: [
'EXTENSION_DISABLED',
'SKATTEVERKET_NOT_CONNECTED',
'SKATTEVERKET_ACCESS_DENIED',
'SKATTEVERKET_RATE_LIMITED',
'SKATTEVERKET_API_ERROR',
'VALIDATION_ERROR',
],
},
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'skatteverket.vat_declarations.get',
async (request, ctx) => {
const url = new URL(request.url)
const parsed = Query.safeParse({
period_type: url.searchParams.get('period_type') ?? undefined,
year: url.searchParams.get('year') ?? undefined,
period: url.searchParams.get('period') ?? undefined,
state: url.searchParams.get('state') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
// The skatteverket extension is opt-in (extensions.config.json) and the
// registry is the runtime source of truth: absent registration means the
// deployment does not offer the integration at all.
const services = extensionRegistry.get('skatteverket')?.services as
| Partial<SkatteverketReadServices>
| undefined
if (!services?.fetchVatDeclarationStatus) {
return v1ErrorResponseFromCode('EXTENSION_DISABLED', ctx.log, {
requestId: ctx.requestId,
})
}
const result = await services.fetchVatDeclarationStatus(
ctx.supabase,
ctx.userId,
ctx.companyId!,
{
periodType: parsed.data.period_type,
year: parsed.data.year,
period: parsed.data.period,
state: parsed.data.state,
},
)
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
status: result.http_status,
details: { message: result.error },
})
}
return ok(
{
redovisare: result.redovisare,
redovisningsperiod: result.redovisningsperiod,
submitted: result.submitted ?? null,
decided: result.decided ?? null,
},
{ requestId: ctx.requestId },
)
},
)
@@ -0,0 +1,245 @@
/**
* Tests for the registry-exposed read service fetchVatDeclarationStatus
* (issue #1663): the SKATTEVERKET_ENABLED gate, the #1673 company-scoped
* read-auth model, 404 → null semantics for inlamnat/beslutat, and the
* structured error mapping the v1 route depends on.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
const mockSkvRequestWithAuth = vi.fn()
vi.mock('../lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, skvRequestWithAuth: (...a: unknown[]) => mockSkvRequestWithAuth(...a) }
})
const mockResolveReadAuth = vi.fn()
vi.mock('../lib/resolve-auth', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, resolveReadAuth: (...a: unknown[]) => mockResolveReadAuth(...a) }
})
const mockResolveRedovisare = vi.fn()
vi.mock('../lib/declaration-prep', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, resolveRedovisare: (...a: unknown[]) => mockResolveRedovisare(...a) }
})
const mockResolvePeriodDates = vi.fn()
vi.mock('@/lib/reports/vat-declaration', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, resolvePeriodDates: (...a: unknown[]) => mockResolvePeriodDates(...a) }
})
const mockWriteAudit = vi.fn()
vi.mock('../lib/audit', () => ({
writeSkatteverketAudit: (...a: unknown[]) => mockWriteAudit(...a),
}))
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 type { SupabaseClient } from '@supabase/supabase-js'
import { fetchVatDeclarationStatus } from '../lib/declaration-status'
import { SkatteverketAuthError } from '../lib/api-client'
const supabase = {} as SupabaseClient
const USER_AUTH = { mode: 'user', supabase, userId: 'user-2', companyId: 'company-1' }
function skvJson(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
}
}
let prevEnv: string | undefined
beforeEach(() => {
vi.clearAllMocks()
prevEnv = process.env.SKATTEVERKET_ENABLED
process.env.SKATTEVERKET_ENABLED = 'true'
mockResolveRedovisare.mockResolvedValue('165560000167')
mockResolveReadAuth.mockResolvedValue({
ok: true,
auth: USER_AUTH,
source: 'user',
tokenUserId: 'user-2',
})
})
afterEach(() => {
if (prevEnv === undefined) delete process.env.SKATTEVERKET_ENABLED
else process.env.SKATTEVERKET_ENABLED = prevEnv
})
describe('fetchVatDeclarationStatus', () => {
it('flag off → EXTENSION_DISABLED, zero SKV calls', async () => {
delete process.env.SKATTEVERKET_ENABLED
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3,
})
expect(result).toMatchObject({ ok: false, code: 'EXTENSION_DISABLED', http_status: 503 })
expect(mockSkvRequestWithAuth).not.toHaveBeenCalled()
})
it('missing org number → VALIDATION_ERROR 400', async () => {
mockResolveRedovisare.mockRejectedValue(
new Error('Organisationsnummer saknas i företagsinställningar'),
)
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3,
})
expect(result).toMatchObject({ ok: false, code: 'VALIDATION_ERROR', http_status: 400 })
expect(mockSkvRequestWithAuth).not.toHaveBeenCalled()
})
it('no company token → SKATTEVERKET_NOT_CONNECTED 401', async () => {
mockResolveReadAuth.mockResolvedValue({ ok: false, reason: 'no_token' })
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3,
})
expect(result).toMatchObject({
ok: false, code: 'SKATTEVERKET_NOT_CONNECTED', http_status: 401,
})
expect(mockSkvRequestWithAuth).not.toHaveBeenCalled()
})
it('needs_reconsent → SKATTEVERKET_NOT_CONNECTED with reconnect message', async () => {
mockResolveReadAuth.mockResolvedValue({ ok: false, reason: 'needs_reconsent' })
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3,
})
expect(result).toMatchObject({ ok: false, code: 'SKATTEVERKET_NOT_CONNECTED' })
expect((result as { error: string }).error).toContain('förnyas')
})
it('happy path both: inlamnat + beslutat via the company-resolved auth (#1673)', async () => {
mockSkvRequestWithAuth
.mockResolvedValueOnce(skvJson(200, { skatt: 12500 }))
.mockResolvedValueOnce(skvJson(200, { beslut: 'FASTSTALLT' }))
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'quarterly', year: 2026, period: 1,
})
expect(result).toEqual({
ok: true,
redovisare: '165560000167',
redovisningsperiod: '202603',
submitted: { skatt: 12500 },
decided: { beslut: 'FASTSTALLT' },
})
// The resolved (possibly another member's) auth is what hits SKV: the
// caller's own uid is only a preference inside resolveReadAuth.
expect(mockResolveReadAuth).toHaveBeenCalledWith(supabase, 'company-1', {
requires: 'moms_ombud', userId: 'user-1',
})
expect(mockSkvRequestWithAuth.mock.calls[0]).toEqual([
USER_AUTH, 'GET', '/inlamnat/165560000167/202603',
])
expect(mockSkvRequestWithAuth.mock.calls[1]).toEqual([
USER_AUTH, 'GET', '/beslutat/165560000167/202603',
])
// One regulator audit row per SKV view.
expect(mockWriteAudit).toHaveBeenCalledTimes(2)
expect(mockWriteAudit.mock.calls[0][1]).toMatchObject({ endpoint: 'inlamnat', outcome: 'ok' })
expect(mockWriteAudit.mock.calls[1][1]).toMatchObject({ endpoint: 'beslutat', outcome: 'ok' })
})
it('404 from SKV means nothing on file: null sections, ok audit outcome', async () => {
mockSkvRequestWithAuth
.mockResolvedValueOnce(skvJson(404, {}))
.mockResolvedValueOnce(skvJson(404, {}))
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 7,
})
expect(result).toEqual({
ok: true,
redovisare: '165560000167',
redovisningsperiod: '202607',
submitted: null,
decided: null,
})
expect(mockWriteAudit.mock.calls[0][1]).toMatchObject({ outcome: 'ok', responseStatus: 404 })
})
it("state='submitted' only calls inlamnat and leaves decided null", async () => {
mockSkvRequestWithAuth.mockResolvedValueOnce(skvJson(200, { skatt: 1 }))
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3, state: 'submitted',
})
expect(result).toMatchObject({ ok: true, submitted: { skatt: 1 }, decided: null })
expect(mockSkvRequestWithAuth).toHaveBeenCalledTimes(1)
expect(mockSkvRequestWithAuth.mock.calls[0][2]).toBe('/inlamnat/165560000167/202603')
})
it("state='decided' only calls beslutat and leaves submitted null", async () => {
mockSkvRequestWithAuth.mockResolvedValueOnce(skvJson(200, { beslut: 'X' }))
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3, state: 'decided',
})
expect(result).toMatchObject({ ok: true, submitted: null, decided: { beslut: 'X' } })
expect(mockSkvRequestWithAuth).toHaveBeenCalledTimes(1)
expect(mockSkvRequestWithAuth.mock.calls[0][2]).toBe('/beslutat/165560000167/202603')
})
it('yearly resolves the fiscal-year end month (broken räkenskapsår)', async () => {
mockResolvePeriodDates.mockResolvedValue({ start: '2025-07-01', end: '2026-06-30' })
mockSkvRequestWithAuth.mockResolvedValue(skvJson(404, {}))
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'yearly', year: 2026, period: 1,
})
expect(result).toMatchObject({ ok: true, redovisningsperiod: '202606' })
})
it('upstream non-404 error → SKATTEVERKET_API_ERROR 502 with skv_error audit', async () => {
mockSkvRequestWithAuth.mockResolvedValueOnce(skvJson(500, { fel: 'internt' }))
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3,
})
expect(result).toMatchObject({ ok: false, code: 'SKATTEVERKET_API_ERROR', http_status: 502 })
expect((result as { error: string }).error).toContain('500')
// The upstream body is logged server-side only, never forwarded to the
// API consumer (it can leak Skatteverket system details).
expect((result as { error: string }).error).not.toContain('internt')
expect(mockWriteAudit.mock.calls[0][1]).toMatchObject({ outcome: 'skv_error' })
})
it('2xx with an unparseable body → SKATTEVERKET_API_ERROR 502 with skv_error audit', async () => {
mockSkvRequestWithAuth.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError('Unexpected end of JSON input')
},
text: async () => '',
})
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3,
})
expect(result).toMatchObject({ ok: false, code: 'SKATTEVERKET_API_ERROR', http_status: 502 })
expect(mockWriteAudit.mock.calls[0][1]).toMatchObject({
outcome: 'skv_error',
responseStatus: 200,
})
})
it('SkatteverketAuthError → structured code via skvAuthCodeToStructured', async () => {
mockSkvRequestWithAuth.mockRejectedValueOnce(
new SkatteverketAuthError('Behörighet saknas', 'BEHORIGHET_SAKNAS'),
)
const result = await fetchVatDeclarationStatus(supabase, 'user-1', 'company-1', {
periodType: 'monthly', year: 2026, period: 3,
})
expect(result).toMatchObject({
ok: false, code: 'SKATTEVERKET_ACCESS_DENIED', http_status: 403,
})
})
})
+5
View File
@@ -45,6 +45,7 @@ import {
agiKontrolleraIU,
} from './lib/agi-client'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { fetchVatDeclarationStatus } from './lib/declaration-status'
import { runPostConnectRefresh } from './lib/post-connect-refresh'
import { readAgiSubmissionStatus } from './lib/agi-submission-status'
import {
@@ -2502,6 +2503,10 @@ export const skatteverketExtension: Extension = {
services: {
commitSubmitVatDeclaration,
commitSubmitAgi,
// Read service for the v1 REST endpoint (issue #1663): filed
// momsdeklarationer (inlamnat) and beslut (beslutat). Contract in
// lib/skatteverket/declaration-status.ts.
fetchVatDeclarationStatus,
},
}
@@ -0,0 +1,179 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
import { resolvePeriodDates } from '@/lib/reports/vat-declaration'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import type {
SkvVatDeclarationStatusInput,
SkvVatDeclarationStatusResult,
} from '@/lib/skatteverket/declaration-status'
import { skvRequestWithAuth, SkatteverketAuthError } from './api-client'
import { resolveReadAuth } from './resolve-auth'
import { resolveRedovisare } from './declaration-prep'
import { skvAuthCodeToStructured } from './error-map'
import { writeSkatteverketAudit } from './audit'
/**
* Registry-resolved read service for filed momsdeklarationer (issue #1663).
*
* Fetches Skatteverket's /inlamnat (the declaration as submitted) and/or
* /beslutat (the beslut) views for one period, so API consumers (v1 REST) can
* compare their books against actually-filed data. Read-only on SKV's side;
* the only local writes are the regulator audit rows.
*
* Auth follows the company-scoped read model (#1673, resolve-auth.ts): the
* caller's own token when they connected, otherwise any other member's active
* token, otherwise system credentials with a verified ombud grant. The fetched
* declaration belongs to the company, not to whoever pressed "Anslut".
*
* Error contract: known failure modes return `{ ok: false, code, http_status,
* error }` with structured codes so the v1 route maps them deterministically;
* unexpected errors are thrown and handled by the route wrapper.
*/
export async function fetchVatDeclarationStatus(
supabase: SupabaseClient,
userId: string,
companyId: string,
input: SkvVatDeclarationStatusInput,
): Promise<SkvVatDeclarationStatusResult> {
// Direct service calls bypass the HTTP dispatcher's SKATTEVERKET_ENABLED
// gate (app/api/extensions/ext/[...path]/route.ts), so check the flag here:
// same reasoning as the commit services in index.ts.
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
return {
ok: false,
code: 'EXTENSION_DISABLED',
http_status: 503,
error: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
}
}
const state = input.state ?? 'both'
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
let redovisare: string
try {
redovisare = await resolveRedovisare(supabase, companyId)
} catch (err) {
// Missing org number in company settings: a configuration problem the
// caller can fix, not a server error.
return {
ok: false,
code: 'VALIDATION_ERROR',
http_status: 400,
error:
err instanceof Error ? err.message : 'Organisationsnummer saknas i företagsinställningar',
}
}
try {
// Helårsmoms is filed per räkenskapsår (SFL 26 kap 10-11 §§): a broken
// fiscal year ends in its own month, not December. Same resolution as
// buildMomsuppgift so the period identifier matches what was filed.
let fiscalYearEnd: { year: number; month: number } | undefined
if (input.periodType === 'yearly') {
const { end } = await resolvePeriodDates(
supabase, companyId, input.periodType, input.year, input.period,
)
fiscalYearEnd = { year: Number(end.slice(0, 4)), month: Number(end.slice(5, 7)) }
}
const redovisningsperiod = formatRedovisningsperiod(
input.periodType, input.year, input.period, fiscalYearEnd,
)
const resolved = await resolveReadAuth(supabase, companyId, {
requires: 'moms_ombud',
userId,
})
if (!resolved.ok) {
return {
ok: false,
code: 'SKATTEVERKET_NOT_CONNECTED',
http_status: 401,
error:
resolved.reason === 'needs_reconsent'
? 'Anslutningen mot Skatteverket behöver förnyas. Anslut igen med BankID.'
: 'Inte ansluten till Skatteverket.',
}
}
const fetchView = async (
view: 'inlamnat' | 'beslutat',
): Promise<
| { ok: true; body: unknown }
| { ok: false; failure: Extract<SkvVatDeclarationStatusResult, { ok: false }> }
> => {
const res = await skvRequestWithAuth(
resolved.auth, 'GET', `/${view}/${redovisare}/${redovisningsperiod}`,
)
// Parse the 2xx body BEFORE writing the audit row, so a success status
// with an unreadable body is recorded as skv_error, not 'ok', and maps
// to SKATTEVERKET_API_ERROR instead of escaping as an internal 500.
let body: unknown = null
let bodyUnparseable = false
if (res.ok) {
try {
body = await res.json()
} catch {
bodyUnparseable = true
}
}
// 404 means "nothing on file for the period": a normal answer, not an
// upstream failure. Same audit convention as the MCP status tool.
const upstreamOk = (res.ok && !bodyUnparseable) || res.status === 404
await writeSkatteverketAudit(ctx, {
endpoint: view,
agRegistreradId: redovisare,
redovisningsperiod,
outcome: upstreamOk ? 'ok' : 'skv_error',
responseStatus: res.status,
})
if (res.status === 404) return { ok: true, body: null }
if (!res.ok || bodyUnparseable) {
// The upstream body is logged server-side only. Forwarding it verbatim
// to API consumers would leak Skatteverket system details; the caller
// gets the status code and a generic Swedish message.
const text = bodyUnparseable
? '<2xx body was not valid JSON>'
: await res.text().catch(() => '')
ctx.log.warn('skv declaration-status upstream error', {
view,
status: res.status,
body: text.slice(0, 500),
})
return {
ok: false,
failure: {
ok: false,
code: 'SKATTEVERKET_API_ERROR',
http_status: 502,
error: bodyUnparseable
? 'Skatteverket svarade med ett svar som inte kunde tolkas.'
: `Skatteverket svarade med ${res.status}.`,
},
}
}
return { ok: true, body }
}
let submitted: unknown = null
let decided: unknown = null
if (state === 'submitted' || state === 'both') {
const result = await fetchView('inlamnat')
if (!result.ok) return result.failure
submitted = result.body
}
if (state === 'decided' || state === 'both') {
const result = await fetchView('beslutat')
if (!result.ok) return result.failure
decided = result.body
}
return { ok: true, redovisare, redovisningsperiod, submitted, decided }
} catch (err) {
if (err instanceof SkatteverketAuthError) {
const mapped = skvAuthCodeToStructured(err.code)
return { ok: false, code: mapped.code, http_status: mapped.httpStatus, error: err.message }
}
throw err
}
}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `124`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `125`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -52,6 +52,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"GET /api/v1/companies/:companyId/salary-runs/:id/employees",
"GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
"GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf",
"GET /api/v1/companies/:companyId/skatteverket/vat-declarations",
"GET /api/v1/companies/:companyId/supplier-invoices",
"GET /api/v1/companies/:companyId/supplier-invoices/:id",
"GET /api/v1/companies/:companyId/suppliers",
+3
View File
@@ -164,4 +164,7 @@ import '@/app/api/v1/companies/[companyId]/articles/route'
// #1348: company-settings write (PATCH, MCP-tool-identical field set).
import '@/app/api/v1/companies/[companyId]/settings/route'
// #1663: filed momsdeklaration read (SKV inlamnat/beslutat).
import '@/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route'
export {}
+3
View File
@@ -108,6 +108,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation': 'bookkeeping:write',
// Compliance check (Accounted's defensible edge).
'GET /api/v1/companies/:companyId/compliance/check': 'compliance:read',
// #1663: filed momsdeklaration read (SKV inlamnat/beslutat). Rides
// compliance:read, mirroring the MCP gnubok_vat_declaration_status mapping.
'GET /api/v1/companies/:companyId/skatteverket/vat-declarations': 'compliance:read',
// Phase 4 PR-3: Documents (multipart).
'POST /api/v1/companies/:companyId/documents': 'documents:write',
'GET /api/v1/companies/:companyId/documents/:id/download': 'documents:read',
+5
View File
@@ -3342,6 +3342,11 @@ const SKATTEVERKET: Record<string, StructuredErrorEntry> = {
message_en: 'Skatteverket rate limit exceeded.',
retryable: true,
},
SKATTEVERKET_API_ERROR: {
httpStatus: 502,
message_sv: 'Skatteverkets tjänst svarade med ett fel. Se detaljerna och försök igen.',
message_en: 'The Skatteverket API returned an error. See details for the upstream message.',
},
}
// ─────────────────────────────────────────────────────────────────
+62
View File
@@ -0,0 +1,62 @@
/**
* Core <-> Skatteverket-extension read boundary for filed VAT declarations.
*
* `lib/` and `app/api/v1/` cannot import from `@/extensions/` (CI guard,
* core-build.yml), so the v1 REST read endpoint reaches the Skatteverket
* extension only through the registry-resolved `services` channel: same
* pattern as lib/pending-operations/skatteverket-commit.ts. This module
* defines the SHARED shapes so the extension (which may import core freely)
* and the v1 route agree on the contract without core ever importing the
* extension.
*/
import type { VatPeriodType } from '@/types'
/** Which Skatteverket view(s) to fetch. */
export type SkvVatDeclarationState = 'submitted' | 'decided' | 'both'
export interface SkvVatDeclarationStatusInput {
periodType: VatPeriodType
year: number
/** 1-12 for monthly, 1-4 for quarterly, 1 for yearly. */
period: number
/** Defaults to 'both'. */
state?: SkvVatDeclarationState
}
/** Result returned by the extension's fetchVatDeclarationStatus. */
export type SkvVatDeclarationStatusResult =
| {
ok: true
/** 12-digit Skatteverket redovisare identifier for the company. */
redovisare: string
/** Skatteverket period identifier (YYYYMM: the period's last month). */
redovisningsperiod: string
/**
* Skatteverket's /inlamnat body (the declaration as filed), or null when
* nothing has been submitted for the period or state='decided'.
*/
submitted: unknown
/**
* Skatteverket's /beslutat body (the beslut), or null when Skatteverket
* has not decided the period yet or state='submitted'.
*/
decided: unknown
}
| {
ok: false
/** Structured error code (see lib/errors/structured-errors.ts). */
code: string
http_status: number
error: string
}
/** Read services a fully-wired skatteverket extension exposes on `services`. */
export interface SkatteverketReadServices {
fetchVatDeclarationStatus: (
supabase: unknown,
userId: string,
companyId: string,
input: SkvVatDeclarationStatusInput,
) => Promise<SkvVatDeclarationStatusResult>
}
+3 -2
View File
@@ -67,10 +67,11 @@ const GROUPS: Array<{ file: string; title: string; members: string[]; blurb: str
{
file: 'periods.md',
title: 'Periods and registers',
members: ['fiscal-periods', 'accounts', 'compliance', 'dimensions'],
members: ['fiscal-periods', 'accounts', 'compliance', 'dimensions', 'skatteverket'],
blurb:
'Fiscal periods and their lock/close/year-end lifecycle (async operations), the BAS ' +
'chart of accounts, cost-center/project dimensions, and the compliance pre-flight check.',
'chart of accounts, cost-center/project dimensions, the compliance pre-flight check, ' +
'and reading filed VAT declarations (and beslut) from Skatteverket.',
},
{
file: 'invoices.md',
+4 -3
View File
@@ -8,7 +8,7 @@ description: >-
transactions and reconciliation, payroll (lön), VAT/moms and financial
reports, SIE import/export, documents, webhooks. Covers auth with
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
pagination, scopes), and all 124 endpoints.
pagination, scopes), and all 125 endpoints.
---
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
@@ -140,7 +140,7 @@ call can undo it, e.g. invoice credit).
## Endpoint index
API version `2026-05-12`, 124 operations. Paths are shown without
API version `2026-05-12`, 125 operations. Paths are shown without
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
### Core (4)
@@ -169,7 +169,7 @@ POST /companies/{companyId}/journal-entries/batch-create : Create up to 50 draft
POST /companies/{companyId}/voucher-gap-explanations : Document a gap in the verifikationsserie (BFL 5 kap 6-7 §§) [scope:bookkeeping:write risk:low idempotent dry-run]
```
### Periods and registers (12)
### Periods and registers (13)
Full detail: [references/periods.md](references/periods.md)
@@ -186,6 +186,7 @@ POST /companies/{companyId}/fiscal-periods/{id}/currency-revaluation : Run FX re
POST /companies/{companyId}/fiscal-periods/{id}/lock : Lock a fiscal period (no new entries can be posted into it) [scope:bookkeeping:write risk:high idempotent reversible]
POST /companies/{companyId}/fiscal-periods/{id}/opening-balances : Generate opening-balance verifikation for the next fiscal period [scope:bookkeeping:write risk:high idempotent reversible]
POST /companies/{companyId}/fiscal-periods/{id}/year-end : Execute year-end closing (currency revaluation + closing entry) [scope:bookkeeping:write risk:high idempotent]
GET /companies/{companyId}/skatteverket/vat-declarations : Read a filed momsdeklaration (submitted and/or decided) from Skatteverket [scope:compliance:read risk:low idempotent]
```
### Invoices (AR) (10)
+37 -1
View File
@@ -2,7 +2,7 @@
# Periods and registers endpoints
Fiscal periods and their lock/close/year-end lifecycle (async operations), the BAS chart of accounts, cost-center/project dimensions, and the compliance pre-flight check.
Fiscal periods and their lock/close/year-end lifecycle (async operations), the BAS chart of accounts, cost-center/project dimensions, the compliance pre-flight check, and reading filed VAT declarations (and beslut) from Skatteverket.
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors)
are in SKILL.md and are not repeated per endpoint.
@@ -500,3 +500,39 @@ Response `200`:
}
}
```
---
### `GET /api/v1/companies/{companyId}/skatteverket/vat-declarations`
**Read a filed momsdeklaration (submitted and/or decided) from Skatteverket.**
`scope:compliance:read · risk:low · idempotent`
Fetches the momsdeklaration for one period as Skatteverket has it on file: `submitted` is the declaration as filed (SKV /inlamnat), `decided` is Skatteverket's beslut (SKV /beslutat). Either section is null when nothing is on file for the period (or when excluded via ?state=). Query params: period_type (monthly|quarterly|yearly), year, period (1-12 monthly, 1-4 quarterly, 1 yearly), optional state (submitted|decided|both, default both). Requires the company to have an active Skatteverket connection (any member's BankID connection, or a verified ombud grant). Live read against Skatteverket, not a cached copy.
**Use when:** You want to verify what was actually filed for a VAT period, compare a period against last year's filed declaration, or check whether Skatteverket has decided a period.
**Do not use for:** Computing the declaration from the books (use the VAT report), or filing: submission is a separate BankID-signed flow.
**Pitfalls:**
- This is a live Skatteverket read: it fails with SKATTEVERKET_NOT_CONNECTED (401) until someone in the company has connected with BankID under Installningar, and the response reflects SKV's state, not the books.
- submitted=null and decided=null with HTTP 200 means "nothing on file for the period": it is not an error.
- A submitted declaration can lack a beslut for days: poll decided separately rather than assuming both appear together.
- redovisningsperiod is SKV's YYYYMM format (the period's LAST month): quarterly period 1 is 03, not 01.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
Response `200`:
```ts
{
data: { redovisare: string, redovisningsperiod: string, submitted?: unknown, decided?: unknown },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```