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

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

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

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

Fixes #1663

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

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

Consolidated fixes for PR #1773 review round:

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-21 12:12:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 0de766c6a4
commit 60920ec794
15 changed files with 949 additions and 7 deletions
@@ -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 },
)
},
)