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:
co-authored by
Claude Fable 5
parent
0de766c6a4
commit
60920ec794
@@ -228,6 +228,47 @@ processing_activities:
|
|||||||
- bankid_signing_required_for_filing
|
- bankid_signing_required_for_filing
|
||||||
- immutable_audit_log
|
- 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
|
- id: psd2.cash_account_mirror
|
||||||
name: PSD2-konton speglas till cash_accounts
|
name: PSD2-konton speglas till cash_accounts
|
||||||
purpose: >-
|
purpose: >-
|
||||||
|
|||||||
@@ -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] 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] 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] 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-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-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).
|
[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).
|
||||||
|
|||||||
+194
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
agiKontrolleraIU,
|
agiKontrolleraIU,
|
||||||
} from './lib/agi-client'
|
} from './lib/agi-client'
|
||||||
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
|
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 { runPostConnectRefresh } from './lib/post-connect-refresh'
|
||||||
import { readAgiSubmissionStatus } from './lib/agi-submission-status'
|
import { readAgiSubmissionStatus } from './lib/agi-submission-status'
|
||||||
import {
|
import {
|
||||||
@@ -2502,6 +2503,10 @@ export const skatteverketExtension: Extension = {
|
|||||||
services: {
|
services: {
|
||||||
commitSubmitVatDeclaration,
|
commitSubmitVatDeclaration,
|
||||||
commitSubmitAgi,
|
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
|
// 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`] = `
|
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",
|
||||||
"GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
|
"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/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",
|
||||||
"GET /api/v1/companies/:companyId/supplier-invoices/:id",
|
"GET /api/v1/companies/:companyId/supplier-invoices/:id",
|
||||||
"GET /api/v1/companies/:companyId/suppliers",
|
"GET /api/v1/companies/:companyId/suppliers",
|
||||||
|
|||||||
@@ -164,4 +164,7 @@ import '@/app/api/v1/companies/[companyId]/articles/route'
|
|||||||
// #1348: company-settings write (PATCH, MCP-tool-identical field set).
|
// #1348: company-settings write (PATCH, MCP-tool-identical field set).
|
||||||
import '@/app/api/v1/companies/[companyId]/settings/route'
|
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 {}
|
export {}
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
|||||||
'POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation': 'bookkeeping:write',
|
'POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation': 'bookkeeping:write',
|
||||||
// Compliance check (Accounted's defensible edge).
|
// Compliance check (Accounted's defensible edge).
|
||||||
'GET /api/v1/companies/:companyId/compliance/check': 'compliance:read',
|
'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).
|
// Phase 4 PR-3: Documents (multipart).
|
||||||
'POST /api/v1/companies/:companyId/documents': 'documents:write',
|
'POST /api/v1/companies/:companyId/documents': 'documents:write',
|
||||||
'GET /api/v1/companies/:companyId/documents/:id/download': 'documents:read',
|
'GET /api/v1/companies/:companyId/documents/:id/download': 'documents:read',
|
||||||
|
|||||||
@@ -3342,6 +3342,11 @@ const SKATTEVERKET: Record<string, StructuredErrorEntry> = {
|
|||||||
message_en: 'Skatteverket rate limit exceeded.',
|
message_en: 'Skatteverket rate limit exceeded.',
|
||||||
retryable: true,
|
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.',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -67,10 +67,11 @@ const GROUPS: Array<{ file: string; title: string; members: string[]; blurb: str
|
|||||||
{
|
{
|
||||||
file: 'periods.md',
|
file: 'periods.md',
|
||||||
title: 'Periods and registers',
|
title: 'Periods and registers',
|
||||||
members: ['fiscal-periods', 'accounts', 'compliance', 'dimensions'],
|
members: ['fiscal-periods', 'accounts', 'compliance', 'dimensions', 'skatteverket'],
|
||||||
blurb:
|
blurb:
|
||||||
'Fiscal periods and their lock/close/year-end lifecycle (async operations), the BAS ' +
|
'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',
|
file: 'invoices.md',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >-
|
|||||||
transactions and reconciliation, payroll (lön), VAT/moms and financial
|
transactions and reconciliation, payroll (lön), VAT/moms and financial
|
||||||
reports, SIE import/export, documents, webhooks. Covers auth with
|
reports, SIE import/export, documents, webhooks. Covers auth with
|
||||||
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
|
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`. -->
|
<!-- 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
|
## 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`).
|
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
|
||||||
|
|
||||||
### Core (4)
|
### 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]
|
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)
|
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}/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}/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]
|
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)
|
### Invoices (AR) (10)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Periods and registers endpoints
|
# 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)
|
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors)
|
||||||
are in SKILL.md and are not repeated per endpoint.
|
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[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user