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:
@@ -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: >-
|
||||
|
||||
@@ -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).
|
||||
|
||||
+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,
|
||||
} 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",
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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',
|
||||
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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user