Files
accounted/extensions/general/skatteverket/lib/api-client.ts
T
Jakob WennbergandClaude Opus 4.7 432a8b60dc feat(skatteverket): rewrite AGI flow against real Skatteverket RAML (#391)
* feat(skatteverket): rewrite AGI flow against real Skatteverket RAML

The previous AGI client posted JSON to URL paths that don't exist on
Skatteverket's gateway and used invented field names. POST /underlag
actually accepts application/xml, and the lock/kvittenser operations
live on the separate hanteraredovisningsperiod API. Verified against
dev_docs/arbetsgivardeklaration-inlamning(1.7.7) and
arbetsgivardeklaration-hantera-redovisningsperiod(1.2.8) RAMLs.

- Replace fictional types with real schemas (kontrollresultat,
  granskningsunderlag, kvittenser, error envelope)
- Rewrite agi-client into 9 functions matching the documented flow:
  /underlag (XML) -> kontrollresultat -> spara -> skapaGranskningsunderlag
  -> kvittenser, plus las/lasUpp on the hantera API
- Drop agi-mappers entirely; lib/salary/agi/xml-generator.ts already
  produces schema-valid XML, so the extension just feeds
  agi_declarations.xml_content to POST /underlag
- Extend skvRequest with a contentType option so AGI can post XML
- AGIPanel state machine: underlag_submitted -> awaiting_signing ->
  signed, with kontrollresultat polling and normalized findings
- Add the agd OAuth scope (confirmed from SKV's Tjanstebeskrivning
  Arbetsgivardeklaration inlamning v1.7, section 4.1.2.2)
- Add Skatteverket connect step to NewUserChecklist alongside the
  existing SIE/old-system import and bank steps; track
  hasSkatteverketConnected in OnboardingProgress
- Update orchestrator route + tests to point at the new /agi/submit
  endpoint
- Declare new optional base-URL env vars in the manifest

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): address PR review findings on AGI flow

- Surface INCORRECT_DATA felrapport link in AGIPanel
  skapaGranskningsunderlag returns 409 with a felrapport URL when SKV
  rejects the underlag. The link was persisted as `signeringslank` with
  status `underlag_rejected`, but the render condition only fired for
  `awaiting_signing`, leaving the link unreachable. Add a distinct
  destructive-styled block so the user can open the felrapport in Mina
  Sidor.

- /agi/underlag DELETE clears local submission state
  Add optional `period` query param. When supplied, clear
  `agi_submission_{period}` directly. When not, fall back to scanning
  recent agi_submission_* keys for the matching inlamningId. Without
  this, an aborted underlag left a stale `underlag_submitted` entry in
  extension_data and the UI couldn't progress.

- Re-add salary-run status guard inside loadAGIXml
  The orchestrator at app/api/salary/runs/[id]/agi/submit/route.ts has
  this check, but the extension endpoint is also reachable directly
  from AGIPanel and must enforce it itself. Per BFL 5 kap and SFL
  26 kap, AGI must reflect finalised payroll data; submitting from a
  draft/cancelled run would emit incorrect figures.

- Move agi_declarations.status='exported' from /agi/submit to /agi/spara
  Setting status on underlag-ingest was wrong because a DONE_REJECTED
  kontrollresultat would leave the row falsely marked as exported. The
  transition now happens only after the spara call commits the underlag
  to Eget utrymme. /agi/spara accepts salaryRunId in the body for the
  fast path and falls back to scanning agi_submission_* state otherwise.

- Move salary_runs.agi_submitted_at stamp to kvittenser observation
  The orchestrator was stamping at underlag-ingest, but no later code
  updated the column on signing. Removed the orchestrator stamp; the
  /agi/kvittenser handler now stamps salary_runs.agi_submitted_at to
  kvittens.signeradTid (mirroring SKV's own timestamp) when it pins
  the receipt to the matching agi_declarations row.

- Tighten misleading JSDoc in agi-client.ts
  taBortSparadInlamning is on the inlämning API, not hantera; the old
  layout grouped it under a "hantera API" heading and tripped an
  automated reviewer. Restructured into separate "period management"
  and "cleanup" blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): address Swedish compliance review on AGI flow

Follow-up to review on https://github.com/erp-mafia/gnubok/pull/391.

- Migration adds 'pending_signature' to agi_declarations.status
  Reusing 'exported' for the spara→kvittens interval misstated the
  filing outcome — Eget utrymme is a staging area, not a filing — which
  conflicts with BFNAR 2013:2 kap 8 / BFL 5 kap 5§ behandlingshistorik
  faithfulness. /agi/spara now sets 'pending_signature'; /agi/kvittenser
  later promotes to 'submitted' when a uuidKvittens is observed.

- AGIPanel auto-polls /agi/kvittenser at 30s, 2 min and 5 min after the
  signing link is created
  Previously the kvittens (and therefore salary_runs.agi_submitted_at)
  was only stamped if the user manually returned to the panel and
  clicked "Hämta kvittens". Without that follow-up the audit trail
  showed a NULL submitted-at for an AGI that had actually been filed.
  Background polls capture the kvittens for the common case where the
  user signs in Mina Sidor and never returns to gnubok. Cleanup on
  unmount via useRef + useEffect.

- Distinct MISSING_SCOPE error code on 403 invalid_scope
  Existing tokens lack the new 'agd' scope and surface as a generic
  ACCESS_DENIED today. The compliance reviewer pointed out that
  operators may interpret this as a data error and submit a corrected
  AGI with altered figures. New SkatteverketAuthError code maps SKV's
  invalid_scope body to a clear "reconnect via Inställningar →
  Skatteverket" message; routes to 401 (token-level remediation).

- Refine deadline copy in AGIPanel
  The standard AGI deadline is the 12th regardless of company size; the
  17th only applies in January and August for employers with turnover
  ≤ 40 MSEK. Surface that nuance instead of saying just "12:e".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): server-side kvittens reconciliation cron

Round 2 of compliance review on https://github.com/erp-mafia/gnubok/pull/391.

- /api/extensions/skatteverket/agi/kvittenser/cron
  Walks every agi_declarations row in 'pending_signature' status, fetches
  kvittenser via the matching token, and on a hit promotes the row to
  'submitted' + stamps salary_runs.agi_submitted_at. Authoritative source
  for the audit trail per BFNAR 2013:2 kap 8 / BFL 5 kap 5§ — the
  AGIPanel client-side timers from the previous round remain as the
  fast-path UX, but no longer carry the audit-trail responsibility on
  their own. Per-row errors are skipped, not abort-the-run. 50s budget.
  Scheduled every 2 hours in vercel.json.

- AGIStatus union now includes 'pending_signature'
  Without this update, downstream code reading the union would have
  rejected the new status as unknown. The migration extending the DB
  CHECK constraint shipped in the previous commit; this brings the type
  layer into sync.

- Stale comment update in AGIPanel.tsx
  Referred to status='exported' from before the rename. Now reads
  'pending_signature', matching the actual handler behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): close audit-trail gaps from compliance round 3

- Cron now writes submitted_by from the token-owning auth.users row
  Previously left NULL with a "system actor" comment. The token row was
  created when the operator authenticated with BankID, and the kvittens'
  signeradAv refers to the same person — so writing the user_id from
  skatteverket_tokens captures actor traceability without inventing a
  system identity. Closes the BFL 5 kap 6§ / BFNAR 2013:2 kap 8 gap on
  cron-reconciled rows.

- /agi/spara monotonicity guard
  Adds .in('status', ['generated', 'exported']) to the row update so a
  delayed /agi/spara call after the cron (or interactive /agi/kvittenser)
  has already promoted the row to 'submitted'/'accepted' won't silently
  regress it back to 'pending_signature'. behandlingshistorik must
  advance only.

- DONE_REJECTED / DONE_FAILED → status='rejected'
  /agi/kontrollresultat handler now flips the matching agi_declarations
  row to 'rejected' on a terminal SKV failure, using the same
  cached-submission-state lookup pattern /agi/spara already uses.
  Without this the row sat at 'generated' indefinitely even though SKV
  considered the underlag failed. Same monotonicity guard prevents
  regressing a successfully-filed row.

- Deadline criterion: lönesumma, not omsättning
  AGIPanel pendingText. SFL 26 kap's relaxed-deadline criterion (17:e
  in Jan/Aug) is the employer's total taxable wages, not turnover.
  Internal reference (.claude/skills/swedish-payroll/references/agi-filing.md)
  used the colloquial "turnover"; statutory wording is "lönesumma".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): close round-4 audit-trail and UX gaps

- agi_submitted_at: NULL when signeradTid absent
  Both /agi/kvittenser handler and the kvittens cron previously
  fell back to new Date().toISOString() if SKV's kvittens lacked
  signeradTid. Substituting wall-clock now() falsifies the filing
  moment in behandlingshistorik (BFNAR 2013:2 kap 8 / BFL 5 kap 6§).
  Now leaves the column NULL and logs a warning. Status flip to
  'submitted' still happens — the audit gap was timing only.

- Proactive missing-agd-scope banner
  SkatteverketConnectPanel and AGIPanel now warn when the stored
  token lacks the agd scope. Tokens issued before the agd rollout
  would otherwise 403 with invalid_scope at submission time, often
  too close to the AGI deadline. SkatteverketConnectPanel mirrors
  the existing "skattekonto saknas" pattern; AGIPanel surfaces a
  banner in the connected state and links to /settings/skatteverket.

- Granskningsunderlag isError keys on tillstand only
  Previous check mixed HTTP 409 with the INCORRECT_DATA tillstand
  string. A future SKV addition like RECEIVING returned with HTTP
  200 would have slipped through as awaiting_signing. Now keys
  solely on tillstand: only LOCKED_FOR_SIGNING / UNLOCKED are
  treated as signable; everything else (INCORRECT_DATA, RECEIVING,
  CALCULATING, SIGNING) routes to underlag_rejected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): close round-5 audit-trail and recovery gaps

- agi_submitted_at: stamp with reconciliation time when signeradTid absent
  Round 4 left the column NULL on missing signeradTid to avoid falsifying
  the signing moment. Round 5 pointed out that NULL hides that the filing
  *occurred* — also a behandlingshistorik integrity violation. Resolution:
  presence of uuidKvittens proves SKV signed and accepted the AGI, so we
  stamp with signeradTid || now() and warn-log when fallback is used.
  Both /agi/kvittenser handler and the kvittens cron.

- Persist signeradAv + full kvittens in agi_declarations.response_data
  submitted_by is the auth.users UUID we have on hand (the polling /
  reconciling user). The legally load-bearing signer identity is
  kvittens.signeradAv (a personnummer) — which the token user_id does
  NOT necessarily match (e.g. bookkeeper vs deklarationsombud). The
  existing response_data jsonb column now holds the full kvittens record,
  preserving signeradAv for the audit trail (BFL 5 kap 6§ / BFNAR 2013:2
  kap 8) without a schema change. Cron path also marks reconciledBy='cron'.

- /agi/spara monotonicity: allow recovery from 'rejected'
  Previously .in('status', ['generated', 'exported']) excluded rejected
  rows, so a successful re-submission after a prior rejection couldn't
  promote the row to pending_signature — it silently stayed rejected.
  The xml-route reuses the same agi_declarations row when re-generating
  XML, so this is the realistic recovery path. Added 'rejected' to the
  allowed-from list. 'submitted'/'accepted' still blocked (no regression
  from filed states).

- Fix misleading agi-client.ts comment
  Claimed users could "fix the errors in Mina Sidor" after a
  DONE_REJECTED save. Mina Sidor doesn't expose in-place editing; the
  correct recovery is to regenerate XML and resubmit. Updated the
  agiSparaUnderlag JSDoc to describe the actual flow.

- Deadline copy: "vars sammanlagda lönesumma understiger 40 MSEK"
  Reads more cleanly than "≤ 40 MSEK" and matches the phrasing the
  compliance reviewer suggested.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skatteverket): tighten /agi/spara guard and clarify deadline copy (round 6)

- Drop 'exported' from /agi/spara allowed-from states
  Audit confirmed no code path writes status='exported' today; the value
  is preserved in the schema (and union) for the legacy manual-download
  path that no longer has a writer. Allowing the spara handler to flip
  an 'exported' row to 'pending_signature' would conflate two distinct
  filing attempts on a single row, weakening the chain of custody (BFL
  5 kap 6§). Tightened to .in(['generated', 'rejected']) — same recovery
  path for re-submission after rejection, no path for the dormant state.

- Deadline copy: explicit "per år" qualifier
  The 40 MSEK threshold is annual lönesumma, not per-payment. Adding
  "per år" closes the (admittedly thin) misread the compliance reviewer
  flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:08:29 +02:00

268 lines
9.3 KiB
TypeScript

import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { refreshAccessToken } from './oauth'
import { getTokens, storeTokens } from './token-store'
import type { SkatteverketTokens } from '../types'
/**
* Skatteverket API client.
*
* Handles:
* - Automatic token refresh (transparent to callers)
* - Required API gateway headers
* - Rate limiting (4 req/sec per consumer)
* - Correlation ID generation
*/
const DEFAULT_API_BASE_URL = 'https://api.test.skatteverket.se/momsdeklaration/v1'
const MAX_REFRESH_COUNT = 10
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000 // Refresh 5 min before expiry
// Simple in-memory token bucket for 4 req/sec rate limit
let lastRequestTime = 0
const MIN_REQUEST_INTERVAL_MS = 250 // 1000ms / 4 = 250ms
function getApiBaseUrl(): string {
return process.env.SKATTEVERKET_API_BASE_URL || DEFAULT_API_BASE_URL
}
function getApiGwClientId(): string {
const id = process.env.SKATTEVERKET_APIGW_CLIENT_ID
if (!id) throw new Error('SKATTEVERKET_APIGW_CLIENT_ID is required')
return id
}
function getApiGwClientSecret(): string {
const secret = process.env.SKATTEVERKET_APIGW_CLIENT_SECRET
if (!secret) throw new Error('SKATTEVERKET_APIGW_CLIENT_SECRET is required')
return secret
}
/**
* Ensure rate limit compliance (4 req/sec).
* Delays if the last request was too recent.
*/
async function enforceRateLimit(): Promise<void> {
const now = Date.now()
const elapsed = now - lastRequestTime
lastRequestTime = now // Claim the slot immediately to prevent concurrent bypass
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
await new Promise(resolve => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed))
}
}
// Coalesce concurrent refresh attempts within this Node.js process. Without
// this, two parallel SKV requests from the same user (e.g. rapid UI clicks)
// would both call SKV's /token endpoint with the same refresh_token; SKV
// rotates that token on first use, so the second call would fail with 401.
// Cross-process races (separate Vercel function instances) are mitigated by
// the re-read inside the critical section: if another process refreshed
// while we waited on the network, we just use that newer token.
const refreshInFlight = new Map<string, Promise<string>>()
/**
* Get a valid access token, refreshing if needed.
* Throws if no tokens exist or refresh is exhausted.
*/
async function getValidToken(
supabase: SupabaseClient,
userId: string
): Promise<string> {
const tokens = await getTokens(supabase, userId)
if (!tokens) {
throw new SkatteverketAuthError(
'Inte ansluten till Skatteverket. Anslut med BankID först.',
'NOT_CONNECTED'
)
}
// Token still valid (with 5-min margin)
if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
return tokens.access_token
}
// Need refresh — coalesce concurrent attempts.
const inFlight = refreshInFlight.get(userId)
if (inFlight) return inFlight
const promise = refreshTokenForUser(supabase, userId)
.finally(() => refreshInFlight.delete(userId))
refreshInFlight.set(userId, promise)
return promise
}
async function refreshTokenForUser(
supabase: SupabaseClient,
userId: string,
): Promise<string> {
// Re-read after entering the critical section. Another process may have
// refreshed while we were waiting; if so, the row now has a new
// refresh_token and a future expiry — just hand it back.
const tokens = await getTokens(supabase, userId)
if (!tokens) {
throw new SkatteverketAuthError(
'Inte ansluten till Skatteverket. Anslut med BankID först.',
'NOT_CONNECTED'
)
}
if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
return tokens.access_token
}
if (!tokens.refresh_token) {
throw new SkatteverketAuthError(
'Sessionen har gått ut. Logga in med BankID igen.',
'SESSION_EXPIRED'
)
}
if (tokens.refresh_count >= MAX_REFRESH_COUNT) {
throw new SkatteverketAuthError(
'Maximalt antal förnyelser uppnått. Logga in med BankID igen.',
'REFRESH_EXHAUSTED'
)
}
const refreshed = await refreshAccessToken(tokens.refresh_token, tokens.refresh_count)
const updatedTokens: SkatteverketTokens = {
...refreshed,
scope: tokens.scope,
}
await storeTokens(supabase, userId, updatedTokens)
return updatedTokens.access_token
}
/**
* Make an authenticated request to the Skatteverket API.
*
* Automatically handles:
* - Token refresh if expired
* - Required headers (Client_Id, Client_Secret, correlation ID)
* - Rate limiting
*/
export async function skvRequest(
supabase: SupabaseClient,
userId: string,
method: string,
path: string,
body?: unknown,
options?: { baseUrl?: string; contentType?: string }
): Promise<Response> {
const accessToken = await getValidToken(supabase, userId)
await enforceRateLimit()
const url = `${options?.baseUrl || getApiBaseUrl()}${path}`
const headers: Record<string, string> = {
'Authorization': `Bearer ${accessToken}`,
'Client_Id': getApiGwClientId(),
'Client_Secret': getApiGwClientSecret(),
'skv_client_correlation_id': crypto.randomUUID(),
}
// contentType defaults to application/json, which is right for moms +
// skattekonto. AGI's POST /underlag takes application/xml — callers pass
// the XML as a string body and override contentType.
let serializedBody: string | undefined
if (body !== undefined) {
const contentType = options?.contentType ?? 'application/json'
headers['Content-Type'] = contentType
serializedBody = typeof body === 'string' ? body : JSON.stringify(body)
}
const response = await fetch(url, {
method,
headers,
body: serializedBody,
})
// Handle Skatteverket-specific auth/throttle errors uniformly so callers
// can catch a single error type rather than parsing status codes inline.
if (response.status === 401) {
throw new SkatteverketAuthError(
'Sessionen har gått ut. Logga in med BankID igen.',
'SESSION_EXPIRED'
)
}
if (response.status === 403) {
const text = await response.text()
// Missing scope on the access token — fires when an existing connection
// pre-dates an extension that needed a new scope (the AGI/`agd` rollout
// is the canonical example). The user has to disconnect + reconnect to
// re-issue a token with the broader scope set; we want to say so
// explicitly instead of letting it surface as a generic 403.
// Body shape per SKV's AGI service description (Tjänstebeskrivning v1.7
// §4.1.2.2): { "error": "invalid_scope", "description": "The required
// scope agd has been requested for that access token." }
if (text.includes('invalid_scope') || text.includes('required scope')) {
throw new SkatteverketAuthError(
'Anslutningen mot Skatteverket saknar nödvändig behörighet för denna ' +
'tjänst. Koppla bort och anslut igen via Inställningar → Skatteverket ' +
'för att förnya tokenen med rätt scope.',
'MISSING_SCOPE'
)
}
// Behörighet saknas — user is authenticated but not authorized for this company
if (text.includes('Behörighet') || text.includes('behörighet')) {
throw new SkatteverketAuthError(
'Du har inte behörighet att agera för detta företag hos Skatteverket. ' +
'Kontrollera att du är registrerad som firmatecknare eller deklarationsombud.',
'BEHORIGHET_SAKNAS'
)
}
throw new SkatteverketAuthError(
`Åtkomst nekad av Skatteverket (403): ${text}`,
'ACCESS_DENIED'
)
}
if (response.status === 429) {
// Skatteverket may include a Retry-After header. We surface a generic
// Swedish message — callers can inspect the header on the thrown error
// if they need to schedule a retry. The 4 req/sec local rate limiter
// should normally prevent this; a 429 here implies the per-consumer
// gateway quota was exceeded.
throw new SkatteverketAuthError(
'Skatteverket är överbelastat eller har strypt anropen. Försök igen om en stund.',
'RATE_LIMITED'
)
}
return response
}
/**
* Structured error for Skatteverket auth/access/throttle issues.
* The `code` field helps the frontend show appropriate UI.
*
* Codes:
* NOT_CONNECTED — no tokens stored; user needs to run BankID flow
* SESSION_EXPIRED — 401 from SKV; refresh exhausted or token rejected
* REFRESH_EXHAUSTED — refresh count hit cap (10) before user re-auth
* BEHORIGHET_SAKNAS — 403 with "Behörighet" body; user not authorized
* for this company at SKV (firmatecknare / ombud)
* MISSING_SCOPE — 403 with "invalid_scope" body; the stored token
* was issued before the required scope existed.
* User must disconnect + reconnect.
* ACCESS_DENIED — generic 403
* RATE_LIMITED — 429 from SKV API gateway
* TOKEN_CORRUPTED — stored tokens cannot be decrypted (key rotated
* or row tampered with); user must reconnect
*/
export class SkatteverketAuthError extends Error {
constructor(
message: string,
public readonly code:
| 'NOT_CONNECTED'
| 'SESSION_EXPIRED'
| 'REFRESH_EXHAUSTED'
| 'BEHORIGHET_SAKNAS'
| 'MISSING_SCOPE'
| 'ACCESS_DENIED'
| 'RATE_LIMITED'
| 'TOKEN_CORRUPTED'
) {
super(message)
this.name = 'SkatteverketAuthError'
}
}