Skv/e2e overview (#515)
* feat(agi): refactor AGI XML generation and data handling - Remove deprecated AGI field codes from field-codes.ts. - Update generate-declaration.ts to include new employee fields and handle absence data with stable specification numbers. - Enhance XML generation in xml-generator.ts to support new flags for housing benefits and adjusted benefits. - Introduce new database migrations to support: - `removed_from_agi` flag for tombstoning individuppgifter. - `benefits_adjusted` flag for tracking adjustments to benefits. - `franvaro_specifikationsnummer` for stable absence event identification. - `housing_benefit_type` to differentiate between housing benefit types. * feat: Implement strict validation for AGI employee data and introduce pre-flight validation schemas - Added Zod schemas for validating employee data in AGI declarations to ensure all required fields are present and correctly typed, preventing silent errors during processing. - Introduced AGI pre-flight validation schemas for Skatteverket endpoints to validate individual and head unit submissions before sending to the API. - Created a new audit log table for tracking all outbound calls to Skatteverket, ensuring compliance and traceability for AGI and moms submissions. - Implemented advisory locks in the database to manage concurrent updates to absence specification numbers, enhancing data integrity. - Added compliance documentation for GDPR processing activities related to AGI and moms submissions, detailing data handling and retention policies. * feat: Extend DELETE RLS policy to protect 'declined' signatures in årsredovisning * fix: Update date handling in salary absence migrations to use immutable year-month key * fix: Update SELECT policy in skatteverket_api_audit_log to use IN clause for company_id * feat: Add skatteverket_api_audit_log and salary_absence_franvaro_audit tables with RLS policies and immutable triggers
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
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'
|
||||
@@ -17,6 +22,8 @@ import {
|
||||
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'
|
||||
@@ -46,6 +53,14 @@ import type { VatPeriodType } from '@/types'
|
||||
* 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
|
||||
@@ -81,6 +96,91 @@ import type { VatPeriodType } from '@/types'
|
||||
* 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',
|
||||
@@ -1252,6 +1352,231 @@ export const skatteverketExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── 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.
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SkatteverketAGIErrorBody,
|
||||
SkatteverketAGIGranskningsunderlagResponse,
|
||||
SkatteverketAGIKontrollresultat,
|
||||
SkatteverketAGIKontrollsvar,
|
||||
SkatteverketAGIKvittenserResponse,
|
||||
SkatteverketAGIUnderlagResponse,
|
||||
} from '../types'
|
||||
@@ -355,3 +356,65 @@ export async function agiLasUppPeriod(
|
||||
const data = await response.json().catch(() => ({}))
|
||||
return { ok: true, status: response.status, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /underlag/huvuduppgift/kontrollera — pre-flight validation of a single
|
||||
* HU as JSON without saving anything. Returns the kontrollsvar (OK / INFO /
|
||||
* ARENDE / STOPP / AVVISANDE) and a list of any fel that fired.
|
||||
*
|
||||
* Use this to surface validation errors per HU to the user before they
|
||||
* generate and submit a full XML underlag. The JSON property names follow
|
||||
* the v1.7 spec §7 — see lib/salary/agi/huvuduppgift-json.ts for the typed
|
||||
* builder.
|
||||
*/
|
||||
export async function agiKontrolleraHU(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
hu: Record<string, unknown>,
|
||||
): Promise<Result<SkatteverketAGIKontrollsvar>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'POST',
|
||||
'/underlag/huvuduppgift/kontrollera',
|
||||
hu,
|
||||
{ baseUrl: getInlamningBaseUrl() },
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const { error, body } = await readErrorBody(response)
|
||||
return { ok: false, status: response.status, error, body }
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SkatteverketAGIKontrollsvar
|
||||
return { ok: true, status: response.status, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /underlag/individuppgift/kontrollera — pre-flight validation of a
|
||||
* single IU as JSON without saving anything. JSON property names follow
|
||||
* the v1.7 spec §8 — see lib/salary/agi/individuppgift-json.ts for the
|
||||
* typed builder.
|
||||
*/
|
||||
export async function agiKontrolleraIU(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
iu: Record<string, unknown>,
|
||||
): Promise<Result<SkatteverketAGIKontrollsvar>> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'POST',
|
||||
'/underlag/individuppgift/kontrollera',
|
||||
iu,
|
||||
{ baseUrl: getInlamningBaseUrl() },
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const { error, body } = await readErrorBody(response)
|
||||
return { ok: false, status: response.status, error, body }
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SkatteverketAGIKontrollsvar
|
||||
return { ok: true, status: response.status, data }
|
||||
}
|
||||
|
||||
@@ -213,6 +213,25 @@ export interface SkatteverketAGIErrorBody {
|
||||
meddelandeTillUtvecklare?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from POST /underlag/huvuduppgift/kontrollera and
|
||||
* /underlag/individuppgift/kontrollera (Skatteverket v1.7 §6.6 kontrollsvar).
|
||||
*
|
||||
* Validates a single HU or IU as JSON without storing it. `fel` is empty
|
||||
* when status === 'OK'. AVVISANDE means the payload was malformed enough
|
||||
* to skip rule evaluation; STOPP is a hard validation failure that would
|
||||
* reject the underlag.
|
||||
*/
|
||||
export interface SkatteverketAGIKontrollsvar {
|
||||
status: 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE'
|
||||
fel: SkatteverketAGIKontrollsvarFel[]
|
||||
}
|
||||
|
||||
export interface SkatteverketAGIKontrollsvarFel {
|
||||
status: 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE'
|
||||
felmeddelande?: string
|
||||
}
|
||||
|
||||
export interface SkatteverketSubmission {
|
||||
id: string
|
||||
user_id: string
|
||||
|
||||
Reference in New Issue
Block a user