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