Files
accounted/extensions/general/skatteverket/index.ts
T
Jakob WennbergandClaude Opus 4.8 c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

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

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00

2090 lines
82 KiB
TypeScript

import crypto from 'crypto'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import {
AGIKontrolleraHUSchema,
AGIKontrolleraIUSchema,
AGI_KONTROLLERA_MAX_BYTES,
} from '@/lib/salary/agi/kontrollera-schemas'
import { TimeoutError } from '@/lib/http/fetch-with-timeout'
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
import { skvRequest, SkatteverketAuthError, getSkatteverketEnvironment } from './lib/api-client'
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import {
agiPostUnderlag,
agiGetKontrollresultat,
agiSparaUnderlag,
agiAvbrytUnderlag,
agiTaBortSparadInlamning,
agiSkapaGranskningsunderlag,
agiGetKvittenser,
agiLasPeriod,
agiLasUppPeriod,
agiKontrolleraHU,
agiKontrolleraIU,
} from './lib/agi-client'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
import {
findMatchCandidates,
findMatchSuggestionsBulk,
matchSkattekontoToEntry,
SkattekontoMatchError,
} from './lib/skattekonto-match'
import { splitTransactions } from './lib/skattekonto-buckets'
import type { SkattekontoBalanceSnapshot } from './types'
import type { VatPeriodType } from '@/types'
/**
* Skatteverket integration extension.
*
* Enables filing momsdeklaration (VAT declaration) and arbetsgivardeklaration
* (AGI), plus Skattekonto saldo sync. Users authenticate with BankID via the
* `per` (e-legitimation) OAuth2 flow.
*
* Required environment variables:
* - SKATTEVERKET_OAUTH2_CLIENT_ID
* - SKATTEVERKET_OAUTH2_CLIENT_SECRET
* - SKATTEVERKET_APIGW_CLIENT_ID
* - SKATTEVERKET_APIGW_CLIENT_SECRET
* - SKATTEVERKET_TOKEN_ENCRYPTION_KEY (openssl rand -base64 32; never reuse
* the test-env key in prod)
*
* Optional:
* - SKATTEVERKET_ENV — 'test' | 'production'.
* Drives security-relevant
* limits (AGI payload size:
* 100 MB test vs 300 MB prod).
* Defaults to 'test' (stricter)
* when unset or unrecognised.
* MUST be set explicitly in
* every deployment manifest.
* - SKATTEVERKET_OAUTH_BASE_URL — defaults to test
* - SKATTEVERKET_API_BASE_URL — momsdeklaration; defaults to test
* - SKATTEVERKET_AGD_INLAMNING_API_BASE_URL — AGI inlämning; defaults to test
* - SKATTEVERKET_AGD_PERIOD_API_BASE_URL — AGI period mgmt; defaults to test
* - SKATTEVERKET_SKATTEKONTO_API_BASE_URL — Skattekonto; defaults to test
* - SKATTEVERKET_DISABLED=true — emergency kill switch
*
* ─── Production cutover checklist ─────────────────────────────────────────
* Before flipping the env URLs to prod, the following has to land first
* (most are external blockers):
*
* 1. Register a prod OAuth2 client in Skatteverket's developer portal
* (separate from the test client). Requires a signed integrationsavtal.
* 2. Order APIGW prod credentials (separate ärende).
* 3. Register the prod redirect URI:
* `${NEXT_PUBLIC_APP_URL}/api/extensions/ext/skatteverket/callback`.
* 4. Request scopes: agd:skicka, agd:lasa, skattekonto:lasa, moms:skicka.
* 5. Pass Skatteverket's godkännandetest (they validate a few real AGI
* submissions in their test tenant before granting prod access).
* 6. Generate a fresh SKATTEVERKET_TOKEN_ENCRYPTION_KEY (rotate from test).
* 7. Set the prod base URLs:
* SKATTEVERKET_API_BASE_URL=https://api.skatteverket.se/momsdeklaration/v1
* SKATTEVERKET_AGD_INLAMNING_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
* SKATTEVERKET_AGD_PERIOD_API_BASE_URL=https://api.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1
* SKATTEVERKET_SKATTEKONTO_API_BASE_URL=https://api.skatteverket.se/beskattning/skattekonto/v2
* SKATTEVERKET_OAUTH_BASE_URL=https://oauth2.skatteverket.se/oauth2
* 8. Verify Sentry alerts on /api/extensions/ext/skatteverket/* 5xx.
* 9. Verify 7-year retention of `agi_declarations.xml_content` +
* `kvittensnummer` (BFL 7 kap.).
* 10. Run a single AGI end-to-end against test on a real client before
* switching that client over.
*
* The /status endpoint reports which environment is active so the UI can
* surface a Testmiljö / Produktion badge.
*/
const AGI_WRITE_ROLES = new Set(['owner', 'admin', 'member'])
/**
* Defense-in-depth RBAC check for AGI write/validate endpoints. Ctx
* presence alone (set by middleware) only confirms the user is signed in
* and has a resolved company; it does NOT prove they are entitled to
* submit tax declarations for that company. Viewers must be blocked.
*
* Returns null on success, a NextResponse on failure.
*/
async function requireAgiWriteRole(ctx: ExtensionContext): Promise<NextResponse | null> {
const { data, error } = await ctx.supabase
.from('company_members')
.select('role')
.eq('company_id', ctx.companyId)
.eq('user_id', ctx.userId)
.maybeSingle()
if (error) {
return NextResponse.json(
{ error: 'Behörighetskontroll misslyckades.' },
{ status: 500 },
)
}
if (!data?.role || !AGI_WRITE_ROLES.has(data.role as string)) {
return NextResponse.json(
{ error: 'Otillräcklig behörighet för att lämna in AGI för det här företaget.' },
{ status: 403 },
)
}
return null
}
/**
* Append an immutable row to skatteverket_api_audit_log. Errors are
* swallowed (logged only) so an audit-table outage does not break the
* regulator flow — but a successful primary call without an audit row
* shows up as a noisy console.error for ops to investigate.
*/
async function writeSkatteverketAudit(
ctx: ExtensionContext,
fields: {
endpoint: string
agRegistreradId?: string | null
redovisningsperiod?: string | null
outcome: 'ok' | 'validation_error' | 'skv_error' | 'auth_error' | 'internal_error'
responseStatus?: number | null
skvStatus?: string | null
requestSizeBytes?: number | null
correlationId?: string | null
errorMessage?: string | null
},
): Promise<void> {
try {
const { error } = await ctx.supabase
.from('skatteverket_api_audit_log')
.insert({
company_id: ctx.companyId,
user_id: ctx.userId,
endpoint: fields.endpoint,
ag_registered_id: fields.agRegistreradId ?? null,
redovisningsperiod: fields.redovisningsperiod ?? null,
outcome: fields.outcome,
response_status: fields.responseStatus ?? null,
skv_status: fields.skvStatus ?? null,
request_size_bytes: fields.requestSizeBytes ?? null,
correlation_id: fields.correlationId ?? null,
error_message: fields.errorMessage ?? null,
})
if (error) {
ctx.log.error('skatteverket_api_audit_log insert failed', {
endpoint: fields.endpoint,
outcome: fields.outcome,
error: error.message,
})
}
} catch (err) {
ctx.log.error('skatteverket_api_audit_log insert threw', {
endpoint: fields.endpoint,
error: err instanceof Error ? err.message : String(err),
})
}
}
export const skatteverketExtension: Extension = {
id: 'skatteverket',
name: 'Skatteverket Integration',
version: '1.0.0',
settingsPanel: {
label: 'Skatteverket',
path: '/settings/account',
},
apiRoutes: [
// ── OAuth: Start authorization ──────────────────────────────────
// Builds the Skatteverket OAuth2 authorize URL and redirects the user
// to BankID login. Stores state token in extension settings for CSRF validation.
{
method: 'GET',
path: '/authorize',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const state = crypto.randomUUID()
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const redirectUri = `${appUrl}/api/extensions/ext/skatteverket/callback`
// Optional: where to send the user after the BankID round-trip.
// Allowlisted to internal in-app paths to avoid open-redirect abuse.
const url = new URL(request.url)
const requestedReturn = url.searchParams.get('return_to')
const returnTo =
requestedReturn && requestedReturn.startsWith('/') && !requestedReturn.startsWith('//')
? requestedReturn
: null
// Generate PKCE pair — verifier persisted server-side, challenge sent
// to SKV. Some SKV per-flow client configurations issue revoked-on-use
// tokens unless PKCE is present, so we always send it.
const pkce = generatePkcePair()
// Store state for CSRF validation in callback
await ctx.settings.set('oauth_state', state)
await ctx.settings.set('oauth_redirect_uri', redirectUri)
await ctx.settings.set('oauth_code_verifier', pkce.verifier)
if (returnTo) await ctx.settings.set('oauth_return_to', returnTo)
else await ctx.settings.clear('oauth_return_to')
const authorizeUrl = buildAuthorizeUrl(redirectUri, state, {
codeChallenge: pkce.challenge,
})
return NextResponse.redirect(authorizeUrl)
},
},
// ── OAuth: Callback ─────────────────────────────────────────────
// Receives the auth code from Skatteverket after BankID login.
// Exchanges code for tokens immediately (5-minute code expiry).
// skipAuth: true — browser redirect from Skatteverket. We handle
// user identification via the stored state token + Supabase session.
{
method: 'GET',
path: '/callback',
skipAuth: true,
handler: async (request: Request) => {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
// JSON-encode for safe embedding inside <script>. Escapes quotes and `</`
// so the value can't break out of the script tag.
const jsLiteral = (value: unknown) =>
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
// Build an HTML response that detects whether we're running inside an
// OAuth popup. If `window.opener` exists, post a message back to the
// parent and close the popup. Otherwise fall back to a plain redirect
// (preserves the legacy non-popup connect flow).
const respondWithSuccess = (fallbackPath: string) => {
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'skatteverket-oauth-success' }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
}
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
const respondWithError = (reason: string, fallbackPath: string) => {
const escapedReason = reason
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'skatteverket-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(`${appUrl}${fallbackPath}`)};
}
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
if (error) {
const desc = url.searchParams.get('error_description') || 'Okänt fel'
return respondWithError(
desc,
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent(desc)}`,
)
}
if (!code || !state) {
return respondWithError(
'Saknar auktoriseringskod',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Saknar auktoriseringskod')}`,
)
}
// Exchange code FIRST — 5-minute expiry, do this before anything else
const { createClient } = await import('@/lib/supabase/server')
const { requireCompanyId } = await import('@/lib/company/context')
const supabase = await createClient()
// Verify user session (browser should still have cookies)
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
// Login redirects always go to a full page — popups can't render the
// login form usefully, so this is the one path that keeps a hard
// redirect even from inside the popup.
return NextResponse.redirect(
`${appUrl}/login?redirect=${encodeURIComponent('/reports?tab=vat-declaration')}`
)
}
// Resolve the active company — state/redirect_uri were stored keyed on company_id
// by ctx.settings.set() in the /authorize handler.
let companyId: string
try {
companyId = await requireCompanyId(supabase, user.id)
} catch {
return respondWithError(
'Inget företag valt',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Inget företag valt')}`,
)
}
// Validate CSRF state
const { data: settingsData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_state')
.single()
if (!settingsData || settingsData.value !== state) {
return respondWithError(
'Ogiltig state-parameter (CSRF)',
`/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Ogiltig state-parameter (CSRF)')}`,
)
}
// Get the stored redirect URI
const { data: redirectData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_redirect_uri')
.single()
const redirectUri = redirectData?.value ||
`${appUrl}/api/extensions/ext/skatteverket/callback`
// Retrieve the PKCE verifier stored in /authorize. Optional only for
// backward compatibility with in-flight flows that started before the
// PKCE rollout — once those drain, this can be made required.
const { data: verifierData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_code_verifier')
.maybeSingle()
const codeVerifier = (verifierData?.value as string | null) || undefined
// Optional in-app destination set by /authorize?return_to=...
const { data: returnToData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_return_to')
.maybeSingle()
const returnTo = (returnToData?.value as string | null) || null
const successPath = returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_connected=true`
: `/reports?tab=vat-declaration&skv_connected=true`
const errorPath = (msg: string) =>
returnTo
? `${returnTo}${returnTo.includes('?') ? '&' : '?'}skv_error=${encodeURIComponent(msg)}`
: `/reports?tab=vat-declaration&skv_error=${encodeURIComponent(msg)}`
try {
const tokens = await exchangeCodeForTokens(code, redirectUri, codeVerifier)
await storeTokens(supabase, user.id, tokens, companyId)
// Clean up CSRF state + the one-shot return_to + PKCE verifier.
await supabase
.from('extension_data')
.delete()
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.in('key', ['oauth_state', 'oauth_return_to', 'oauth_code_verifier'])
return respondWithSuccess(successPath)
} catch (err) {
console.error('[skatteverket] Token exchange failed:', err)
// BankID auth codes expire after 5 minutes. Surface timeouts distinctly
// so the user retries quickly instead of exhausting the code window.
const message = err instanceof TimeoutError
? 'Tidsgränsen mot Skatteverket överskreds — försök igen med BankID'
: err instanceof Error
? err.message
: 'Token exchange misslyckades'
return respondWithError(message, errorPath(message))
}
},
},
// ── Connection status ───────────────────────────────────────────
{
method: 'GET',
path: '/status',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const tokens = await getTokens(ctx.supabase, ctx.userId)
const environment = getSkatteverketEnvironment()
const disabled = (process.env.SKATTEVERKET_DISABLED ?? '').toLowerCase() === 'true'
if (!tokens) {
return NextResponse.json({ connected: false, environment, disabled })
}
const expired = tokens.expires_at < Date.now()
const canRefresh = tokens.refresh_token !== null && tokens.refresh_count < 10
return NextResponse.json({
connected: true,
expired,
canRefresh,
scope: tokens.scope,
expiresAt: new Date(tokens.expires_at).toISOString(),
environment,
disabled,
})
},
},
// ── Disconnect ──────────────────────────────────────────────────
{
method: 'POST',
path: '/disconnect',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
await deleteTokens(ctx.supabase, ctx.userId)
return NextResponse.json({ success: true })
},
},
// ── Validate declaration (dry run) ──────────────────────────────
// Sends momsuppgift to Skatteverket's /kontrollera endpoint.
// Returns ERROR/WARNING/OK without saving anything.
{
method: 'POST',
path: '/declaration/validate',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod, momsuppgift } =
await parseDeclarationRequest(request, ctx)
console.log('[skatteverket] Validating:', {
redovisare,
redovisningsperiod,
momsuppgift: JSON.stringify(momsuppgift),
})
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'POST',
`/kontrollera/${redovisare}/${redovisningsperiod}`,
momsuppgift
)
if (!response.ok) {
const text = await response.text()
console.error('[skatteverket] Validate error:', response.status, text)
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Save draft ──────────────────────────────────────────────────
// Saves momsuppgift to Skatteverket's "Eget utrymme".
// Returns validation results. Optionally lock for signing.
{
method: 'POST',
path: '/declaration/draft',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod, momsuppgift } =
await parseDeclarationRequest(request, ctx)
console.log('[skatteverket] Sending draft:', {
redovisare,
redovisningsperiod,
momsuppgift: JSON.stringify(momsuppgift),
})
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'POST',
`/utkast/${redovisare}/${redovisningsperiod}`,
momsuppgift
)
if (!response.ok) {
const text = await response.text()
console.error('[skatteverket] Draft error:', response.status, text)
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
// Track submission status
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_saved',
redovisare,
redovisningsperiod,
kontrollresultat: data.kontrollresultat,
updatedAt: new Date().toISOString(),
})
)
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Fetch draft ─────────────────────────────────────────────────
{
method: 'GET',
path: '/declaration/draft',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'GET',
`/utkast/${redovisare}/${redovisningsperiod}`
)
if (response.status === 404) {
return NextResponse.json({ data: null })
}
if (!response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Delete draft ────────────────────────────────────────────────
{
method: 'DELETE',
path: '/declaration/draft',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'DELETE',
`/utkast/${redovisare}/${redovisningsperiod}`
)
if (response.status !== 204 && !response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
await ctx.settings.clear(`submission_${redovisningsperiod}`)
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Lock draft for signing ──────────────────────────────────────
// Returns a signeringslänk (deep link) that the user opens
// in a new tab to sign with BankID on Skatteverket's site.
{
method: 'PUT',
path: '/declaration/lock',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'PUT',
`/las/${redovisare}/${redovisningsperiod}`
)
if (!response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_locked',
redovisare,
redovisningsperiod,
signeringsLank: data.signeringsLank,
updatedAt: new Date().toISOString(),
})
)
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Unlock draft ────────────────────────────────────────────────
{
method: 'DELETE',
path: '/declaration/lock',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'DELETE',
`/las/${redovisare}/${redovisningsperiod}`
)
if (response.status !== 204 && !response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
await ctx.settings.set(
`submission_${redovisningsperiod}`,
JSON.stringify({
status: 'draft_saved',
redovisare,
redovisningsperiod,
updatedAt: new Date().toISOString(),
})
)
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Fetch submitted declaration ─────────────────────────────────
{
method: 'GET',
path: '/declaration/submitted',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'GET',
`/inlamnat/${redovisare}/${redovisningsperiod}`
)
if (response.status === 404) {
return NextResponse.json({ data: null })
}
if (!response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Fetch decided declaration ───────────────────────────────────
{
method: 'GET',
path: '/declaration/decided',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { redovisare, redovisningsperiod } = parseQueryParams(request, ctx)
const response = await skvRequest(
ctx.supabase,
ctx.userId,
'GET',
`/beslutat/${redovisare}/${redovisningsperiod}`
)
if (response.status === 404) {
return NextResponse.json({ data: null })
}
if (!response.ok) {
const text = await response.text()
return NextResponse.json(
{ error: `Skatteverket svarade med ${response.status}: ${text}` },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json({ data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ══════════════════════════════════════════════════════════════
// AGI (Arbetsgivardeklaration) routes
//
// AGI submission is XML, not JSON. We feed agi_declarations.xml_content
// (built by lib/salary/agi/xml-generator.ts) to POST /underlag, then poll
// kontrollresultat, save into Eget utrymme, and return a Mina Sidor
// signing link via skapaGranskningsunderlag. After the user signs we
// observe the kvittenser endpoint to record kvittensnummer/signeradTid.
//
// The route surface mirrors the conceptual flow rather than the literal
// SKV endpoints so the frontend stays simple. Two SKV APIs are involved:
// inlamning (XML ingest + JSON status) and hanteraredovisningsperiod
// (kvittenser + las/lasUpp). The agi-client encapsulates both.
// ══════════════════════════════════════════════════════════════
// ── AGI: Submit (POST /underlag with stored XML) ────────────────
// Body: { salaryRunId }. Reads agi_declarations.xml_content for the run,
// posts it to Skatteverket, returns { inlamningId } so the caller can
// poll kontrollresultat. Also persists inlamningId locally for recovery.
{
method: 'POST',
path: '/agi/submit',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const { arbetsgivare, period, salaryRunId, xml } = await loadAGIXml(request, ctx)
console.log('[skatteverket] AGI submitting underlag:', { arbetsgivare, period })
const result = await agiPostUnderlag(ctx.supabase, ctx.userId, xml)
if (!result.ok) {
console.error('[skatteverket] AGI underlag error:', result.status, result.error)
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await ctx.settings.set(
`agi_submission_${period}`,
JSON.stringify({
status: 'underlag_submitted',
arbetsgivare,
period,
salaryRunId,
inlamningId: result.data.inlamningId,
updatedAt: new Date().toISOString(),
}),
)
// Don't flip agi_declarations.status to 'exported' here. SKV's
// kontrollresultat may still come back DONE_REJECTED, in which case
// nothing landed in Eget utrymme. The transition belongs in
// /agi/spara below, after the user (or auto-spara on success) has
// committed the underlag.
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Poll kontrollresultat ──────────────────────────────────
// Query: ?inlamningId=...
// Returns { status: PROCESSING | DONE_SUCCESS | DONE_FAILED | DONE_REJECTED, ... }
//
// Side effect: when SKV reports a terminal failure (DONE_REJECTED or
// DONE_FAILED), promote the matching agi_declarations row to 'rejected'.
// Without this the row would sit at 'generated' indefinitely while SKV's
// own state shows the underlag as failed — misrepresenting the filing
// outcome (BFNAR 2013:2 kap 8 / BFL 5 kap 5§).
{
method: 'GET',
path: '/agi/kontrollresultat',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const inlamningId = Number(url.searchParams.get('inlamningId'))
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
}
const result = await agiGetKontrollresultat(ctx.supabase, ctx.userId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
if (result.data.status === 'DONE_REJECTED' || result.data.status === 'DONE_FAILED') {
// Recover the salaryRunId from cached submission state — same
// fallback mechanism /agi/spara uses. We only update when we can
// identify the row; a missing local cache means we silently skip
// (the alternative would be guessing which declaration to mark).
const { data: rows } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'skatteverket')
.like('key', 'agi_submission_%')
for (const row of rows ?? []) {
try {
const v = JSON.parse(row.value as string) as { inlamningId?: number; salaryRunId?: string }
if (v.inlamningId === inlamningId && v.salaryRunId) {
// Same monotonicity rule as /agi/spara — never regress
// from a successful filing back to 'rejected'.
await ctx.supabase
.from('agi_declarations')
.update({ status: 'rejected' })
.eq('salary_run_id', v.salaryRunId)
.eq('company_id', ctx.companyId)
.in('status', ['generated', 'pending_signature', 'exported'])
break
}
} catch { /* skip malformed */ }
}
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Save underlag into Eget utrymme ────────────────────────
// Body: { inlamningId, salaryRunId? }. Only meaningful between
// POST /underlag and skapaGranskningsunderlag.
//
// Flips agi_declarations.status to 'pending_signature' on success —
// the underlag is durable in SKV's Eget utrymme but is not yet a
// filed declaration. /agi/kvittenser later promotes it to 'submitted'
// when a uuidKvittens (signature receipt) is observed for the period.
// /agi/submit deliberately does NOT update status, because a
// DONE_REJECTED kontrollresultat would leave it falsely pending.
{
method: 'POST',
path: '/agi/spara',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const body = (await request.json()) as { inlamningId?: number; salaryRunId?: string }
const inlamningId = Number(body.inlamningId)
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json({ error: 'Saknar inlamningId' }, { status: 400 })
}
const result = await agiSparaUnderlag(ctx.supabase, ctx.userId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// Promote the matching declaration to 'exported'. salaryRunId is
// accepted in the body for the happy path; if missing we can still
// fall back to the locally-cached submission state, which always
// carries it (we wrote it in /agi/submit).
let runId = body.salaryRunId
if (!runId) {
// Last-resort lookup: scan recent agi_submission_* keys for a
// matching inlamningId. Cheap because there's at most one active
// submission per period and the operator typically has very few.
const { data: rows } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'skatteverket')
.like('key', 'agi_submission_%')
for (const row of rows ?? []) {
try {
const v = JSON.parse(row.value as string) as { inlamningId?: number; salaryRunId?: string }
if (v.inlamningId === inlamningId && v.salaryRunId) {
runId = v.salaryRunId
break
}
} catch { /* skip malformed */ }
}
}
if (runId) {
// Monotonicity guard: only flip from a pre-filing state. If the
// kvittens cron or the interactive /agi/kvittenser handler has
// already promoted this row to 'submitted'/'accepted', don't
// regress it — behandlingshistorik must move forward through
// the filing milestones (BFNAR 2013:2 kap 8).
//
// 'rejected' IS allowed as an originating state: a previous
// submission failed kontrollresultat, the user fixed the XML
// and re-submitted. The same agi_declarations row is reused
// (xml-route updates xml_content in place), so this update
// promotes the recovered submission back to pending_signature.
//
// 'exported' is NOT in the allowed-from list. The status value
// is preserved in the schema for the legacy manual-download
// path (see migration), but no code currently writes it; an
// 'exported' row encountered here would represent a parallel
// filing attempt that should land in its own row, not reuse
// this one (preserves chain of custody per BFL 5 kap 6§).
await ctx.supabase
.from('agi_declarations')
.update({ status: 'pending_signature' })
.eq('salary_run_id', runId)
.eq('company_id', ctx.companyId)
.in('status', ['generated', 'rejected'])
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Avbryt underlag (before spara) ─────────────────────────
// Query: ?inlamningId=...&period=YYYYMM (period optional but recommended)
//
// The `period` param lets the handler clear the locally-cached
// `agi_submission_{period}` record so the UI doesn't sit on a stale
// `underlag_submitted` state. If the caller doesn't pass it we fall
// back to scanning recent submission keys for the matching inlamningId.
{
method: 'DELETE',
path: '/agi/underlag',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const inlamningId = Number(url.searchParams.get('inlamningId'))
const period = url.searchParams.get('period')
if (!Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json({ error: 'Saknar parameter: inlamningId' }, { status: 400 })
}
const result = await agiAvbrytUnderlag(ctx.supabase, ctx.userId, inlamningId)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// Clear locally-cached submission state so the UI doesn't keep
// showing `underlag_submitted` for an inlamning that no longer
// exists at SKV. Direct path: caller passed period.
if (period) {
await ctx.settings.clear(`agi_submission_${period}`)
} else {
// Fallback: find the period by matching inlamningId across
// recent submission keys. Cheap because there's at most one
// active submission per period.
const { data: rows } = await ctx.supabase
.from('extension_data')
.select('key, value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'skatteverket')
.like('key', 'agi_submission_%')
for (const row of rows ?? []) {
try {
const v = JSON.parse(row.value as string) as { inlamningId?: number }
if (v.inlamningId === inlamningId) {
await ctx.supabase
.from('extension_data')
.delete()
.eq('company_id', ctx.companyId)
.eq('extension_id', 'skatteverket')
.eq('key', row.key as string)
break
}
} catch { /* skip malformed */ }
}
}
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Ta bort sparad inlämning (after spara) ─────────────────
// Query: ?arbetsgivare=...&period=YYYYMM&inlamningId=...
{
method: 'DELETE',
path: '/agi/sparad',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
const inlamningId = Number(url.searchParams.get('inlamningId'))
if (!arbetsgivare || !period || !Number.isFinite(inlamningId) || inlamningId <= 0) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
{ status: 400 },
)
}
const result = await agiTaBortSparadInlamning(
ctx.supabase, ctx.userId, arbetsgivare, period, inlamningId,
)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await ctx.settings.clear(`agi_submission_${period}`)
return NextResponse.json({ success: true })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Skapa granskningsunderlag (BankID signing link) ────────
// Query: ?arbetsgivare=...&period=YYYYMM&lasPeriod=true|false
// Returns { link, tillstand, meddelande }. The user opens `link` in a
// new tab and signs with BankID on Skatteverket's site.
{
method: 'POST',
path: '/agi/granskningsunderlag',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
const lasPeriod = url.searchParams.get('lasPeriod') !== 'false' // default true
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 },
)
}
const result = await agiSkapaGranskningsunderlag(
ctx.supabase, ctx.userId, arbetsgivare, period, { lasPeriod },
)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// Persist the link so the user can return to it after a refresh.
// SKV's tillstand enum (per skapagranskningsunderlagsvar.json):
// LOCKED_FOR_SIGNING / UNLOCKED → granskning ready, user can sign
// INCORRECT_DATA → felrapport link, can't sign yet
// RECEIVING / CALCULATING → server still processing
// SIGNING → another signing flow already running
// We key on tillstand alone — keying on HTTP status (e.g. 409) and
// the body string would miss future SKV additions like RECEIVING
// returned with HTTP 200, leaving us in an awaiting_signing state
// when the underlag isn't actually ready.
const canSign =
result.data.tillstand === 'LOCKED_FOR_SIGNING' ||
result.data.tillstand === 'UNLOCKED'
await ctx.settings.set(
`agi_submission_${period}`,
JSON.stringify({
status: canSign ? 'awaiting_signing' : 'underlag_rejected',
arbetsgivare,
period,
signeringslank: result.data.link,
tillstand: result.data.tillstand,
meddelande: result.data.meddelande,
updatedAt: new Date().toISOString(),
}),
)
// Flip the matching agi_declarations row to pending_signature when
// SKV returns a usable granskningsunderlag. Previously this happened
// in /agi/spara, but /spara is only valid for re-saving rejected
// underlag — successful underlag are auto-persisted by SKV, so
// /spara returns felkod 20 on the happy path. Doing the flip here
// ensures the audit trail still moves through pending_signature
// → submitted (BFNAR 2013:2 kap 8 / BFL 5 kap 6§) without /spara.
if (canSign) {
const periodYear = parseInt(period.slice(0, 4))
const periodMonth = parseInt(period.slice(4, 6))
await ctx.supabase
.from('agi_declarations')
.update({ status: 'pending_signature' })
.eq('company_id', ctx.companyId)
.eq('period_year', periodYear)
.eq('period_month', periodMonth)
.in('status', ['generated', 'rejected'])
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Hämta kvittenser (after user signs) ────────────────────
// Query: ?arbetsgivare=...&period=YYYYMM
// Returns the kvittenser array. While the user has not yet signed the
// array is empty; after signing it carries uuidKvittens/signeradAv/-Tid.
{
method: 'GET',
path: '/agi/kvittenser',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 },
)
}
const result = await agiGetKvittenser(ctx.supabase, ctx.userId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
// Newest kvittens for the period drives the local state.
const kvittens = result.data.kvittenser?.[0]
if (kvittens?.uuidKvittens) {
const periodYear = parseInt(period.slice(0, 4))
const periodMonth = parseInt(period.slice(4, 6))
await ctx.settings.set(
`agi_submission_${period}`,
JSON.stringify({
status: 'signed',
arbetsgivare,
period,
kvittensnummer: kvittens.uuidKvittens,
signeradAv: kvittens.signeradAv,
signeradTid: kvittens.signeradTid,
updatedAt: new Date().toISOString(),
}),
)
// Pin the receipt to the most recent declaration for this period
// (id desc) so a correction chain doesn't get its kvittens written
// onto a superseded row. Also stamp salary_runs.agi_submitted_at
// here, mirroring SKV's signeradTid — this is the only place we
// know the AGI was actually filed (the orchestrator deliberately
// doesn't stamp on underlag-ingest, see route.ts comment).
//
// The presence of `uuidKvittens` confirms SKV signed and accepted
// the AGI. signeradTid is the precise signing moment; if SKV
// omits it we fall back to reconciliation time + warn so the
// discrepancy is investigable. Leaving NULL would hide that the
// filing occurred at all, which itself misstates the behandlings-
// historik (BFNAR 2013:2 kap 8 / BFL 5 kap 6§). The fallback
// applies only on this code path because we're inside the
// `if (kvittens?.uuidKvittens)` branch — if no kvittens, no stamp.
const submittedAt = kvittens.signeradTid || new Date().toISOString()
if (!kvittens.signeradTid) {
console.warn('[skatteverket] kvittens missing signeradTid; using reconciliation time', {
companyId: ctx.companyId, period, uuidKvittens: kvittens.uuidKvittens,
})
}
const { data: latest } = await ctx.supabase
.from('agi_declarations')
.select('id, salary_run_id')
.eq('company_id', ctx.companyId)
.eq('period_year', periodYear)
.eq('period_month', periodMonth)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (latest?.id) {
// submitted_by is the auth.users UUID we have on hand (the
// operator who polled the kvittens endpoint). The actual
// BankID signer is identified by kvittens.signeradAv (a
// personnummer string), which we preserve in response_data
// alongside the rest of the receipt — that's the legally
// load-bearing audit record per BFL 5 kap 6§.
await ctx.supabase
.from('agi_declarations')
.update({
status: 'submitted',
kvittensnummer: kvittens.uuidKvittens,
submitted_at: submittedAt,
submitted_by: ctx.userId,
response_data: {
signeradAv: kvittens.signeradAv ?? null,
signeradTid: kvittens.signeradTid ?? null,
uuidKvittens: kvittens.uuidKvittens,
arbetsgivare: kvittens.arbetsgivare ?? null,
period: kvittens.period ?? null,
underlag: kvittens.underlag ?? null,
},
})
.eq('id', latest.id)
if (latest.salary_run_id) {
await ctx.supabase
.from('salary_runs')
.update({ agi_submitted_at: submittedAt })
.eq('id', latest.salary_run_id)
.eq('company_id', ctx.companyId)
}
}
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Pre-flight kontrollera HU/IU ───────────────────────────
// Validate a single HU or IU as JSON without saving anything in
// Skatteverket. Lets the UI catch errors per HU/IU before the user
// submits a full XML underlag. Body: the HU or IU as JSON per the
// v1.7 spec §7 / §8 (property names like agRegistreradId,
// redovisningsPeriod, kontantErsattningUlagAG, …).
//
// Response shape (kontrollsvar):
// { status: 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE',
// fel: [{ status, felmeddelande }, …] }
{
method: 'POST',
path: '/agi/kontrollera/hu',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const rbac = await requireAgiWriteRole(ctx)
if (rbac) return rbac
// Read body as bytes first so we can enforce a hard size cap before
// JSON.parse — protects against ~MB payloads that would otherwise
// hit the Zod validator.
const rawBytes = Buffer.byteLength(await request.clone().text(), 'utf8')
if (rawBytes > AGI_KONTROLLERA_MAX_BYTES) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
outcome: 'validation_error',
responseStatus: 413,
requestSizeBytes: rawBytes,
errorMessage: 'payload exceeds 64KB cap',
})
return NextResponse.json(
{ error: 'Payload överstiger maxstorleken för pre-flight kontroll (64 KB).' },
{ status: 413 },
)
}
let parsedBody: unknown
try {
parsedBody = await request.json()
} catch {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
outcome: 'validation_error',
responseStatus: 400,
requestSizeBytes: rawBytes,
errorMessage: 'invalid JSON',
})
return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 })
}
const parsed = AGIKontrolleraHUSchema.safeParse(parsedBody)
if (!parsed.success) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
outcome: 'validation_error',
responseStatus: 400,
requestSizeBytes: rawBytes,
errorMessage: parsed.error.issues
.map((iss) => `${iss.path.join('.')}: ${iss.message}`)
.join('; ')
.slice(0, 1000),
})
return NextResponse.json(
{
error: 'HU-payload matchar inte Skatteverkets v1.7 §7-schema.',
type: 'validation_error',
errors: parsed.error.issues.map((iss) => ({
field: iss.path.join('.'),
message: iss.message,
code: iss.code,
})),
},
{ status: 400 },
)
}
try {
const result = await agiKontrolleraHU(ctx.supabase, ctx.userId, parsed.data)
if (!result.ok) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: result.status === 401 || result.status === 403 ? 'auth_error' : 'skv_error',
responseStatus: result.status,
requestSizeBytes: rawBytes,
errorMessage: result.error,
})
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: 'ok',
responseStatus: result.status,
skvStatus: result.data?.status ?? null,
requestSizeBytes: rawBytes,
})
return NextResponse.json({ data: result.data })
} catch (err) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.hu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: 'internal_error',
requestSizeBytes: rawBytes,
errorMessage: err instanceof Error ? err.message : String(err),
})
return handleSkvError(err)
}
},
},
{
method: 'POST',
path: '/agi/kontrollera/iu',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const rbac = await requireAgiWriteRole(ctx)
if (rbac) return rbac
const rawBytes = Buffer.byteLength(await request.clone().text(), 'utf8')
if (rawBytes > AGI_KONTROLLERA_MAX_BYTES) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
outcome: 'validation_error',
responseStatus: 413,
requestSizeBytes: rawBytes,
errorMessage: 'payload exceeds 64KB cap',
})
return NextResponse.json(
{ error: 'Payload överstiger maxstorleken för pre-flight kontroll (64 KB).' },
{ status: 413 },
)
}
let parsedBody: unknown
try {
parsedBody = await request.json()
} catch {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
outcome: 'validation_error',
responseStatus: 400,
requestSizeBytes: rawBytes,
errorMessage: 'invalid JSON',
})
return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 })
}
const parsed = AGIKontrolleraIUSchema.safeParse(parsedBody)
if (!parsed.success) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
outcome: 'validation_error',
responseStatus: 400,
requestSizeBytes: rawBytes,
errorMessage: parsed.error.issues
.map((iss) => `${iss.path.join('.')}: ${iss.message}`)
.join('; ')
.slice(0, 1000),
})
return NextResponse.json(
{
error: 'IU-payload matchar inte Skatteverkets v1.7 §8-schema.',
type: 'validation_error',
errors: parsed.error.issues.map((iss) => ({
field: iss.path.join('.'),
message: iss.message,
code: iss.code,
})),
},
{ status: 400 },
)
}
try {
const result = await agiKontrolleraIU(ctx.supabase, ctx.userId, parsed.data)
if (!result.ok) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: result.status === 401 || result.status === 403 ? 'auth_error' : 'skv_error',
responseStatus: result.status,
requestSizeBytes: rawBytes,
errorMessage: result.error,
})
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: 'ok',
responseStatus: result.status,
skvStatus: result.data?.status ?? null,
requestSizeBytes: rawBytes,
})
return NextResponse.json({ data: result.data })
} catch (err) {
await writeSkatteverketAudit(ctx, {
endpoint: 'agi.kontrollera.iu',
agRegistreradId: parsed.data.agRegistreradId,
redovisningsperiod: parsed.data.redovisningsPeriod,
outcome: 'internal_error',
requestSizeBytes: rawBytes,
errorMessage: err instanceof Error ? err.message : String(err),
})
return handleSkvError(err)
}
},
},
// ── AGI: Lås period ─────────────────────────────────────────────
// Hantera-API; typically not needed (skapaGranskningsunderlag already
// accepts lasPeriod=true). Exposed for recovery / manual control.
{
method: 'POST',
path: '/agi/las',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 },
)
}
const result = await agiLasPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Lås upp period ─────────────────────────────────────────
{
method: 'POST',
path: '/agi/lasUpp',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
try {
const url = new URL(request.url)
const arbetsgivare = url.searchParams.get('arbetsgivare')
const period = url.searchParams.get('period')
if (!arbetsgivare || !period) {
return NextResponse.json(
{ error: 'Saknar parametrar: arbetsgivare, period' },
{ status: 400 },
)
}
const result = await agiLasUppPeriod(ctx.supabase, ctx.userId, arbetsgivare, period)
if (!result.ok) {
return NextResponse.json(
{ error: result.error, code: result.body?.kod },
{ status: result.status },
)
}
return NextResponse.json({ data: result.data })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── AGI: Local submission tracking (UI helper) ──────────────────
// Returns the locally-cached submission state (inlamningId, signing link,
// kvittensnummer if seen). Pure read; never calls Skatteverket.
{
method: 'GET',
path: '/agi/status',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
const url = new URL(request.url)
const period = url.searchParams.get('period')
if (!period) return NextResponse.json({ error: 'Saknar parameter: period' }, { status: 400 })
const statusJson = await ctx.settings.get<string>(`agi_submission_${period}`)
if (!statusJson) return NextResponse.json({ data: null })
try {
return NextResponse.json({ data: JSON.parse(statusJson) })
} catch {
return NextResponse.json({ data: null })
}
},
},
// ══════════════════════════════════════════════════════════════
// Skattekonto routes (read-only balance + transactions)
// ══════════════════════════════════════════════════════════════
// ── Saldo (cached snapshot) ────────────────────────────────────
// Returns the most recent saldoResponse cached in extension_data.
// The dashboard uses this for repeated renders without hitting SKV.
// Force a refresh by calling POST /skattekonto/sync first.
{
method: 'GET',
path: '/skattekonto/saldo',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const snapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(SKATTEKONTO_BALANCE_SNAPSHOT_KEY)
const lastSyncedAt = await ctx.settings.get<string>(SKATTEKONTO_LAST_SYNCED_AT_KEY)
return NextResponse.json({
data: snapshot?.saldo ?? null,
fetchedAt: snapshot ? new Date(snapshot.fetchedAt).toISOString() : null,
lastSyncedAt: lastSyncedAt ?? null,
})
},
},
// ── Transaktioner (from local table) ───────────────────────────
// Returns booked + upcoming transactions for the active company.
// Optional `from` query filters tidigare on transaktionsdatum >= from.
{
method: 'GET',
path: '/skattekonto/transaktioner',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const from = url.searchParams.get('from')
let query = ctx.supabase
.from('skattekonto_transactions')
.select('*')
.eq('company_id', ctx.companyId)
.order('transaktionsdatum', { ascending: false })
if (from) query = query.gte('transaktionsdatum', from)
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const rows = data ?? []
const today = new Date().toISOString().slice(0, 10)
const { booked, overdue, upcoming } = splitTransactions(rows, today)
// Enrich obokförda rader with a single-best-candidate suggestion.
// Only attached when there's exactly one match — avoids the UI
// confidently pointing at the wrong verifikat.
const suggestions = await findMatchSuggestionsBulk(
ctx.supabase,
ctx.companyId,
booked.map(r => ({
id: r.id,
transaktionsdatum: r.transaktionsdatum,
belopp_skatteverket: Number(r.belopp_skatteverket),
journal_entry_id: r.journal_entry_id,
})),
)
const bookedEnriched = booked.map(r => ({
...r,
match_suggestion: suggestions.get(r.id) ?? null,
}))
return NextResponse.json({
data: {
booked: bookedEnriched,
overdue,
upcoming,
},
})
},
},
// ── Manual sync ────────────────────────────────────────────────
// Pulls fresh saldo + transactions from Skatteverket and upserts.
{
method: 'POST',
path: '/skattekonto/sync',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
try {
const result = await syncSkattekonto(ctx)
return NextResponse.json({ data: result })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Bokför one row → draft journal entry ──────────────────────
// Creates a DRAFT verifikat in /bookkeeping for the user to review
// and commit. The skattekonto_transactions row is linked via
// journal_entry_id so the UI can show "Bokförd" status.
{
method: 'POST',
path: '/skattekonto/transaktioner/:id/bokfor',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
// Extract :id from the catch-all dispatcher's path-param convention
// (`_id` query string, set in app/api/extensions/ext/[...path]/route.ts).
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
try {
const entry = await bokforSkattekontoTransaction(
ctx.supabase,
ctx.companyId,
ctx.userId,
id,
)
return NextResponse.json({ data: { entry } })
} catch (err) {
if (err instanceof SkattekontoBookingError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: err.code === 'PERIOD_LOCKED' ? 423
: err.code === 'NO_COUNTER_ACCOUNT' ? 422
: 400
return NextResponse.json(
{ error: err.message, code: err.code },
{ status },
)
}
return handleSkvError(err)
}
},
},
// ── Matcha mot befintligt verifikat ──────────────────────────────
// List candidate journal entries already touching 1630 with the right
// amount/side near the transaction date. Lets the user link the SKV
// row to a manually-booked bank transfer instead of creating a duplicate
// verifikat. Returns at most 25 candidates.
{
method: 'GET',
path: '/skattekonto/transaktioner/:id/match-candidates',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
try {
const { candidates } = await findMatchCandidates(ctx.supabase, ctx.companyId, id)
return NextResponse.json({ data: { candidates } })
} catch (err) {
if (err instanceof SkattekontoMatchError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: 400
return NextResponse.json({ error: err.message, code: err.code }, { status })
}
return handleSkvError(err)
}
},
},
// Link the SKV row to a chosen candidate. No new verifikat is created
// — we just write journal_entry_id onto skattekonto_transactions. The
// candidate is re-validated server-side (matching 1630 line, not already
// linked) to catch races and a malicious client.
{
method: 'POST',
path: '/skattekonto/transaktioner/:id/match',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) {
return NextResponse.json({ error: 'Saknar transaktions-id' }, { status: 400 })
}
let body: { journal_entry_id?: string }
try {
body = (await request.json()) as { journal_entry_id?: string }
} catch {
return NextResponse.json({ error: 'Ogiltig request body' }, { status: 400 })
}
if (!body.journal_entry_id || typeof body.journal_entry_id !== 'string') {
return NextResponse.json(
{ error: 'Saknar journal_entry_id' },
{ status: 400 },
)
}
try {
await matchSkattekontoToEntry(
ctx.supabase,
ctx.companyId,
id,
body.journal_entry_id,
)
return NextResponse.json({ data: { ok: true } })
} catch (err) {
if (err instanceof SkattekontoMatchError) {
const status =
err.code === 'TRANSACTION_NOT_FOUND' ? 404
: err.code === 'ENTRY_NOT_FOUND' ? 404
: err.code === 'ALREADY_BOOKED' ? 409
: err.code === 'ENTRY_ALREADY_LINKED' ? 409
: 422
return NextResponse.json({ error: err.message, code: err.code }, { status })
}
return handleSkvError(err)
}
},
},
],
eventHandlers: [
{
eventType: 'skattekonto.drift_detected',
handler: handleSkattekontoDriftDetected,
},
],
}
// ── Helpers ───────────────────────────────────────────────────────────
/**
* Parse and validate declaration request body.
* Computes momsuppgift from Accounted's VAT calculation if not provided directly.
*/
async function parseDeclarationRequest(
request: Request,
ctx: ExtensionContext
): Promise<{
redovisare: string
redovisningsperiod: string
momsuppgift: ReturnType<typeof rutorToMomsuppgift>
}> {
const body = await request.json()
const { periodType, year, period } = body as {
periodType: VatPeriodType
year: number
period: number
}
if (!periodType || !year || !period) {
throw new Error('Saknar obligatoriska fält: periodType, year, period')
}
// Get company settings for redovisare formatting
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', ctx.companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
const redovisare = formatRedovisare(settings.org_number, settings.entity_type)
const redovisningsperiod = formatRedovisningsperiod(periodType, year, period)
// Calculate VAT declaration from the general ledger
const declaration = await calculateVatDeclaration(
ctx.supabase,
ctx.companyId,
periodType,
year,
period
)
const momsuppgift = rutorToMomsuppgift(declaration.rutor)
return { redovisare, redovisningsperiod, momsuppgift }
}
/**
* Parse redovisare and redovisningsperiod from query params.
* Used by GET/PUT/DELETE endpoints that don't need a full body.
*/
function parseQueryParams(
request: Request,
ctx: ExtensionContext
): { redovisare: string; redovisningsperiod: string } {
const url = new URL(request.url)
const redovisare = url.searchParams.get('redovisare')
const redovisningsperiod = url.searchParams.get('redovisningsperiod')
if (!redovisare || !redovisningsperiod) {
throw new Error('Saknar obligatoriska parametrar: redovisare, redovisningsperiod')
}
// Suppress unused variable warning — ctx is required by the type signature
void ctx
return { redovisare, redovisningsperiod }
}
/**
* Load the AGI XML for a salary run from agi_declarations.xml_content
* (built by app/api/salary/runs/[id]/agi/xml/route.ts via generateAGIXml).
*
* Returns the XML alongside the formatted arbetsgivare/period strings used
* downstream by the granskningsunderlag and kvittenser endpoints.
*
* Skatteverket's POST /underlag accepts XML directly; we don't transform it
* here, just plumb it through.
*/
async function loadAGIXml(
request: Request,
ctx: ExtensionContext,
): Promise<{
arbetsgivare: string
period: string
salaryRunId: string
xml: string
}> {
const body = (await request.json()) as { salaryRunId?: string }
const salaryRunId = body.salaryRunId
if (!salaryRunId) {
throw new Error('Saknar obligatoriskt fält: salaryRunId')
}
// Status guard — must mirror the orchestrator at
// app/api/salary/runs/[id]/agi/submit/route.ts. The extension endpoint
// is also reachable directly from AGIPanel, so the check has to live here
// too. Per BFL 5 kap and SFL 26 kap, AGI must reflect finalised payroll
// data; submitting from a draft/cancelled run would emit incorrect figures
// and require a costly rättelse.
const { data: run, error: runError } = await ctx.supabase
.from('salary_runs')
.select('status')
.eq('id', salaryRunId)
.eq('company_id', ctx.companyId)
.single()
if (runError || !run) {
throw new Error('Lönekörning hittades inte')
}
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
throw new Error('AGI kan bara skickas till Skatteverket efter granskning')
}
const { data: settings } = await ctx.supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', ctx.companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
// Use the most recent agi_declarations row for this salary run — covers
// both new declarations and corrections (which overwrite xml_content
// in place per the existing /api/salary/runs/[id]/agi/xml route).
const { data: declaration, error: declarationError } = await ctx.supabase
.from('agi_declarations')
.select('xml_content, period_year, period_month')
.eq('company_id', ctx.companyId)
.eq('salary_run_id', salaryRunId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (declarationError || !declaration?.xml_content) {
throw new Error(
'AGI-XML saknas. Generera AGI-filen från lönekörningen först (Lön → AGI → Generera).',
)
}
const arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
const period = formatRedovisningsperiod('monthly', declaration.period_year, declaration.period_month)
return { arbetsgivare, period, salaryRunId, xml: declaration.xml_content }
}
/**
* Convert Skatteverket errors to appropriate HTTP responses.
*/
function handleSkvError(err: unknown): NextResponse {
if (err instanceof SkatteverketAuthError) {
// MISSING_SCOPE returns 401 — the existing token works, but it doesn't
// grant access to this resource. Treating it as 401 (rather than 403)
// signals to the frontend that the right remediation is to reconnect,
// not to ask the user to gain new authorization at SKV.
const status = err.code === 'NOT_CONNECTED' ? 401
: err.code === 'BEHORIGHET_SAKNAS' ? 403
: err.code === 'SESSION_EXPIRED' || err.code === 'REFRESH_EXHAUSTED' ? 401
: err.code === 'MISSING_SCOPE' ? 401
: err.code === 'TOKEN_CORRUPTED' ? 401
: err.code === 'TOKEN_REVOKED' ? 401
: 403
return NextResponse.json(
{ error: err.message, code: err.code },
{ status }
)
}
console.error('[skatteverket] API error:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Okänt fel' },
{ status: 500 }
)
}