From 60920ec794a8980dffd1ac1dce572abc6fb7c4bb Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:12:18 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Fable 5 --- .compliance/ropa.yaml | 41 +++ DECISIONS.md | 1 + .../vat-declarations/__tests__/route.test.ts | 194 ++++++++++++++ .../skatteverket/vat-declarations/route.ts | 165 ++++++++++++ .../__tests__/declaration-status.test.ts | 245 ++++++++++++++++++ extensions/general/skatteverket/index.ts | 5 + .../skatteverket/lib/declaration-status.ts | 179 +++++++++++++ .../__snapshots__/spec-snapshot.test.ts.snap | 3 +- lib/api/v1/load-routes.ts | 3 + lib/auth/scopes.ts | 3 + lib/errors/structured-errors.ts | 5 + lib/skatteverket/declaration-status.ts | 62 +++++ scripts/api-skill/generate.ts | 5 +- skills/accounted-api/SKILL.md | 7 +- skills/accounted-api/references/periods.md | 38 ++- 15 files changed, 949 insertions(+), 7 deletions(-) create mode 100644 app/api/v1/companies/[companyId]/skatteverket/vat-declarations/__tests__/route.test.ts create mode 100644 app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route.ts create mode 100644 extensions/general/skatteverket/__tests__/declaration-status.test.ts create mode 100644 extensions/general/skatteverket/lib/declaration-status.ts create mode 100644 lib/skatteverket/declaration-status.ts diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index d80a8844..ebc66534 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -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: >- diff --git a/DECISIONS.md b/DECISIONS.md index 33940b65..ed68e932 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1126,6 +1126,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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). diff --git a/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/__tests__/route.test.ts new file mode 100644 index 00000000..98ca6933 --- /dev/null +++ b/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/__tests__/route.test.ts @@ -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('@/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 +const mockServiceClient = createServiceClientNoCookies as ReturnType +const mockRegistryGet = extensionRegistry.get as ReturnType + +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) { + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + 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' }, + ) + }) +}) diff --git a/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route.ts b/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route.ts new file mode 100644 index 00000000..bb7fb8bd --- /dev/null +++ b/app/api/v1/companies/[companyId]/skatteverket/vat-declarations/route.ts @@ -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 + | 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 }, + ) + }, +) diff --git a/extensions/general/skatteverket/__tests__/declaration-status.test.ts b/extensions/general/skatteverket/__tests__/declaration-status.test.ts new file mode 100644 index 00000000..766da8f1 --- /dev/null +++ b/extensions/general/skatteverket/__tests__/declaration-status.test.ts @@ -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 + return { ...actual, skvRequestWithAuth: (...a: unknown[]) => mockSkvRequestWithAuth(...a) } +}) + +const mockResolveReadAuth = vi.fn() +vi.mock('../lib/resolve-auth', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { ...actual, resolveReadAuth: (...a: unknown[]) => mockResolveReadAuth(...a) } +}) + +const mockResolveRedovisare = vi.fn() +vi.mock('../lib/declaration-prep', async (importOriginal) => { + const actual = (await importOriginal()) as Record + 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 + 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, + }) + }) +}) diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index c1e952e6..c466aa38 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -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, }, } diff --git a/extensions/general/skatteverket/lib/declaration-status.ts b/extensions/general/skatteverket/lib/declaration-status.ts new file mode 100644 index 00000000..e321ec43 --- /dev/null +++ b/extensions/general/skatteverket/lib/declaration-status.ts @@ -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 { + // 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 } + > => { + 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 + } +} diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap index e24c53d2..f206c2b6 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -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", diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 99bf0ef9..02f4c783 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -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 {} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 924b37df..81ea2fc0 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -108,6 +108,9 @@ export const V1_ENDPOINT_SCOPES: Record = { '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', diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 7c350192..c33ef4e9 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -3342,6 +3342,11 @@ const SKATTEVERKET: Record = { 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.', + }, } // ───────────────────────────────────────────────────────────────── diff --git a/lib/skatteverket/declaration-status.ts b/lib/skatteverket/declaration-status.ts new file mode 100644 index 00000000..948eaf6f --- /dev/null +++ b/lib/skatteverket/declaration-status.ts @@ -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 +} diff --git a/scripts/api-skill/generate.ts b/scripts/api-skill/generate.ts index 65a01091..dedfefea 100644 --- a/scripts/api-skill/generate.ts +++ b/scripts/api-skill/generate.ts @@ -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', diff --git a/skills/accounted-api/SKILL.md b/skills/accounted-api/SKILL.md index c06563f3..0a0186ec 100644 --- a/skills/accounted-api/SKILL.md +++ b/skills/accounted-api/SKILL.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. --- @@ -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) diff --git a/skills/accounted-api/references/periods.md b/skills/accounted-api/references/periods.md index d22cbb67..7cf07bf5 100644 --- a/skills/accounted-api/references/periods.md +++ b/skills/accounted-api/references/periods.md @@ -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[] + } +} +```