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:
@@ -0,0 +1,127 @@
|
|||||||
|
# Record of Processing Activities (GDPR Art. 30)
|
||||||
|
#
|
||||||
|
# Each entry documents one processing activity. Field shape follows EDPB
|
||||||
|
# Guidelines 9/2022 §3 (controller-side RoPA): purposes, lawful basis,
|
||||||
|
# data subject + data category, recipients, transfer mechanism, retention,
|
||||||
|
# security measures. Add an entry whenever a new flow ships that touches
|
||||||
|
# personal data — onboarding, integrations, support, or regulator filings.
|
||||||
|
|
||||||
|
processing_activities:
|
||||||
|
|
||||||
|
- id: agi.submit
|
||||||
|
name: AGI inlämning till Skatteverket
|
||||||
|
purpose: >-
|
||||||
|
Lämna in lagstadgad arbetsgivardeklaration på individnivå (AGI) till
|
||||||
|
Skatteverket varje månad (Skatteförfarandelagen 26 kap.). Innefattar
|
||||||
|
huvuduppgift (HU) och individuppgifter (IU) per anställd.
|
||||||
|
lawful_basis: art_6_1_c # legal obligation
|
||||||
|
special_category_basis: null
|
||||||
|
controller: gnubok-tenant
|
||||||
|
processor: anthropic-na # software supplier; Skatteverket is recipient, not processor
|
||||||
|
data_subjects:
|
||||||
|
- employee
|
||||||
|
data_categories:
|
||||||
|
- user.government_id # personnummer
|
||||||
|
- user.name
|
||||||
|
- user.financial.employment # gross salary, tax withheld, benefits
|
||||||
|
- user.financial.tax # avgifter, sjuklönekostnad
|
||||||
|
recipients:
|
||||||
|
- name: Skatteverket
|
||||||
|
country: SE
|
||||||
|
role: legal_recipient
|
||||||
|
international_transfers:
|
||||||
|
applicable: false
|
||||||
|
mechanism: null
|
||||||
|
note: >-
|
||||||
|
Sweden-to-Sweden processor-to-public-authority flow. Adequacy decision
|
||||||
|
not applicable; no third-country transfer.
|
||||||
|
retention:
|
||||||
|
duration: 7y
|
||||||
|
basis: bfl_7_kap # BFL 7 kap. — räkenskapsinformation
|
||||||
|
stored_in:
|
||||||
|
- agi_declarations.xml_content
|
||||||
|
- agi_declarations.individuppgifter
|
||||||
|
- skatteverket_api_audit_log
|
||||||
|
security_measures:
|
||||||
|
- encryption_at_rest_supabase
|
||||||
|
- rls_company_scoped
|
||||||
|
- tls_1_3_to_skatteverket
|
||||||
|
- bankid_signing_required_for_filing
|
||||||
|
- immutable_audit_log
|
||||||
|
- personnummer_at_rest_encryption
|
||||||
|
|
||||||
|
- id: agi.kontrollera.preflight
|
||||||
|
name: AGI pre-flight kontrollera (HU/IU)
|
||||||
|
purpose: >-
|
||||||
|
Skicka enskild HU eller IU som JSON till Skatteverkets kontrollera-
|
||||||
|
ändpunkt (v1.7 §7/§8) för dry-run-validering av felmeddelanden och
|
||||||
|
STOPP-/AVVISANDE-status innan ett fullständigt underlag laddas upp.
|
||||||
|
Skatteverket lagrar inget från denna kontroll; svaret driver vår UI
|
||||||
|
som låter användaren rätta felaktigheter i lönekörningen lokalt.
|
||||||
|
lawful_basis: art_6_1_c # legal obligation (ancillary to AGI filing)
|
||||||
|
special_category_basis: null
|
||||||
|
controller: gnubok-tenant
|
||||||
|
processor: anthropic-na
|
||||||
|
data_subjects:
|
||||||
|
- employee
|
||||||
|
data_categories:
|
||||||
|
- user.government_id # personnummer (BetalningsmottagarId)
|
||||||
|
- user.financial.employment # gross salary, tax withheld, benefits
|
||||||
|
recipients:
|
||||||
|
- name: Skatteverket
|
||||||
|
country: SE
|
||||||
|
role: legal_recipient
|
||||||
|
international_transfers:
|
||||||
|
applicable: false
|
||||||
|
mechanism: null
|
||||||
|
note: Sweden-to-Sweden flow; no third-country transfer.
|
||||||
|
retention:
|
||||||
|
# Skatteverket retains nothing for kontrollera calls. Our outbound
|
||||||
|
# audit row in skatteverket_api_audit_log records who-called-what-when
|
||||||
|
# but NOT the payload body, minimising at-rest footprint.
|
||||||
|
duration: 7y
|
||||||
|
basis: bfl_7_kap
|
||||||
|
stored_in:
|
||||||
|
- skatteverket_api_audit_log
|
||||||
|
security_measures:
|
||||||
|
- strict_zod_allow_list_input_validation
|
||||||
|
- request_size_cap_64kb
|
||||||
|
- rbac_company_member_role_check
|
||||||
|
- rls_company_scoped
|
||||||
|
- tls_1_3_to_skatteverket
|
||||||
|
- immutable_audit_log
|
||||||
|
- no_payload_body_persisted
|
||||||
|
|
||||||
|
- id: moms.declaration
|
||||||
|
name: Momsdeklaration till Skatteverket
|
||||||
|
purpose: >-
|
||||||
|
Lämna in lagstadgad momsdeklaration (utkast, lås och signering) till
|
||||||
|
Skatteverkets momsdeklaration-API (Mervärdesskattelagen 17 kap. 24 §).
|
||||||
|
lawful_basis: art_6_1_c
|
||||||
|
special_category_basis: null
|
||||||
|
controller: gnubok-tenant
|
||||||
|
processor: anthropic-na
|
||||||
|
data_subjects:
|
||||||
|
- business_owner
|
||||||
|
data_categories:
|
||||||
|
- user.government_id # orgnummer (juridiska personer) eller pnr
|
||||||
|
- user.financial.tax # rutor 05–62
|
||||||
|
recipients:
|
||||||
|
- name: Skatteverket
|
||||||
|
country: SE
|
||||||
|
role: legal_recipient
|
||||||
|
international_transfers:
|
||||||
|
applicable: false
|
||||||
|
mechanism: null
|
||||||
|
note: Sweden-to-Sweden flow; no third-country transfer.
|
||||||
|
retention:
|
||||||
|
duration: 7y
|
||||||
|
basis: bfl_7_kap
|
||||||
|
stored_in:
|
||||||
|
- extension_data (utkast metadata)
|
||||||
|
- skatteverket_api_audit_log
|
||||||
|
security_measures:
|
||||||
|
- rls_company_scoped
|
||||||
|
- tls_1_3_to_skatteverket
|
||||||
|
- bankid_signing_required_for_filing
|
||||||
|
- immutable_audit_log
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||||
import { NextResponse } from 'next/server'
|
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 { TimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||||
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
|
import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth'
|
||||||
import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
|
import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
|
||||||
@@ -17,6 +22,8 @@ import {
|
|||||||
agiGetKvittenser,
|
agiGetKvittenser,
|
||||||
agiLasPeriod,
|
agiLasPeriod,
|
||||||
agiLasUppPeriod,
|
agiLasUppPeriod,
|
||||||
|
agiKontrolleraHU,
|
||||||
|
agiKontrolleraIU,
|
||||||
} from './lib/agi-client'
|
} from './lib/agi-client'
|
||||||
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
|
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
|
||||||
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
|
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
|
||||||
@@ -46,6 +53,14 @@ import type { VatPeriodType } from '@/types'
|
|||||||
* the test-env key in prod)
|
* the test-env key in prod)
|
||||||
*
|
*
|
||||||
* Optional:
|
* 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_OAUTH_BASE_URL — defaults to test
|
||||||
* - SKATTEVERKET_API_BASE_URL — momsdeklaration; 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_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
|
* The /status endpoint reports which environment is active so the UI can
|
||||||
* surface a Testmiljö / Produktion badge.
|
* 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 = {
|
export const skatteverketExtension: Extension = {
|
||||||
id: 'skatteverket',
|
id: 'skatteverket',
|
||||||
name: 'Skatteverket Integration',
|
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 ─────────────────────────────────────────────
|
// ── AGI: Lås period ─────────────────────────────────────────────
|
||||||
// Hantera-API; typically not needed (skapaGranskningsunderlag already
|
// Hantera-API; typically not needed (skapaGranskningsunderlag already
|
||||||
// accepts lasPeriod=true). Exposed for recovery / manual control.
|
// accepts lasPeriod=true). Exposed for recovery / manual control.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
SkatteverketAGIErrorBody,
|
SkatteverketAGIErrorBody,
|
||||||
SkatteverketAGIGranskningsunderlagResponse,
|
SkatteverketAGIGranskningsunderlagResponse,
|
||||||
SkatteverketAGIKontrollresultat,
|
SkatteverketAGIKontrollresultat,
|
||||||
|
SkatteverketAGIKontrollsvar,
|
||||||
SkatteverketAGIKvittenserResponse,
|
SkatteverketAGIKvittenserResponse,
|
||||||
SkatteverketAGIUnderlagResponse,
|
SkatteverketAGIUnderlagResponse,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
@@ -355,3 +356,65 @@ export async function agiLasUppPeriod(
|
|||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
return { ok: true, status: response.status, data }
|
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
|
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 {
|
export interface SkatteverketSubmission {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
|
|||||||
@@ -287,17 +287,17 @@ describe('buildIndividuppgifterSnapshot', () => {
|
|||||||
expect(snapshot[1].personnummer).toBe('198506159876')
|
expect(snapshot[1].personnummer).toBe('198506159876')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('preserves FK570 for correction reference', () => {
|
it('preserves specificationNumber for correction reference', () => {
|
||||||
const snapshot = buildIndividuppgifterSnapshot(employees)
|
const snapshot = buildIndividuppgifterSnapshot(employees)
|
||||||
expect(snapshot[0].fk570).toBe(1)
|
expect(snapshot[0].specificationNumber).toBe(1)
|
||||||
expect(snapshot[1].fk570).toBe(2)
|
expect(snapshot[1].specificationNumber).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('includes all required rutor', () => {
|
it('includes the core IU amounts', () => {
|
||||||
const snapshot = buildIndividuppgifterSnapshot(employees)
|
const snapshot = buildIndividuppgifterSnapshot(employees)
|
||||||
expect(snapshot[0]).toHaveProperty('ruta011', 40000)
|
expect(snapshot[0]).toHaveProperty('grossSalary', 40000)
|
||||||
expect(snapshot[0]).toHaveProperty('ruta001', 12000)
|
expect(snapshot[0]).toHaveProperty('taxWithheld', 12000)
|
||||||
expect(snapshot[0]).toHaveProperty('ruta020', 40000)
|
expect(snapshot[0]).toHaveProperty('avgifterBasis', 40000)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -312,8 +312,8 @@ describe('generateAGIXml — Frånvarouppgift', () => {
|
|||||||
taxWithheld: 12000,
|
taxWithheld: 12000,
|
||||||
avgifterBasis: 40000,
|
avgifterBasis: 40000,
|
||||||
absenceEvents: [
|
absenceEvents: [
|
||||||
{ date: '2026-04-15', type: 'vab', hours: 8 },
|
{ date: '2026-04-15', type: 'vab', hours: 8, specifikationsnummer: 1 },
|
||||||
{ date: '2026-04-16', type: 'vab', hours: 4 },
|
{ date: '2026-04-16', type: 'vab', hours: 4, specifikationsnummer: 2 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -323,7 +323,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
|
|||||||
taxWithheld: 10500,
|
taxWithheld: 10500,
|
||||||
avgifterBasis: 35000,
|
avgifterBasis: 35000,
|
||||||
absenceEvents: [
|
absenceEvents: [
|
||||||
{ date: '2026-04-20', type: 'parental', hours: 8 },
|
{ date: '2026-04-20', type: 'parental', hours: 8, specifikationsnummer: 1 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -352,26 +352,48 @@ describe('generateAGIXml — Frånvarouppgift', () => {
|
|||||||
company,
|
company,
|
||||||
[{
|
[{
|
||||||
...employeesWithAbsence[1],
|
...employeesWithAbsence[1],
|
||||||
absenceEvents: [{ date: '2026-04-20', type: 'parental', hours: 8 }],
|
absenceEvents: [{ date: '2026-04-20', type: 'parental', hours: 8, specifikationsnummer: 1 }],
|
||||||
}],
|
}],
|
||||||
totals,
|
totals,
|
||||||
)
|
)
|
||||||
expect(xml).not.toMatch(/FranvaroTimmarTFP|FranvaroProcentTFP/)
|
expect(xml).not.toMatch(/FranvaroTimmarTFP|FranvaroProcentTFP/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses 1-based stable specifikationsnummer per employee, ordered by date', () => {
|
it('emits the persisted specifikationsnummer per event (stable across corrections)', () => {
|
||||||
const xml = generateAGIXml(company, employeesWithAbsence, totals)
|
const xml = generateAGIXml(company, employeesWithAbsence, totals)
|
||||||
// emp1 has two events on 2026-04-15 and 2026-04-16
|
// emp1 has two events with specnummer 1 and 2 (assigned by DB trigger);
|
||||||
|
// emp2 has one event with specnummer 1. The values come from the
|
||||||
|
// event object — they are NOT recomputed from array index.
|
||||||
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
|
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
|
||||||
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">2</gem:FranvaroSpecifikationsnummer>')
|
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">2</gem:FranvaroSpecifikationsnummer>')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves the persisted specnummer even when an earlier event is removed', () => {
|
||||||
|
// Simulates the correction scenario the persistence is designed for:
|
||||||
|
// the first vab day was deleted, so the remaining day keeps its
|
||||||
|
// original specnummer (2). Without persistence the index would shift
|
||||||
|
// to 1 and Skatteverket would treat it as a replacement of the
|
||||||
|
// already-filed day-1 event.
|
||||||
|
const xml = generateAGIXml(
|
||||||
|
company,
|
||||||
|
[{
|
||||||
|
...employeesWithAbsence[0],
|
||||||
|
absenceEvents: [
|
||||||
|
{ date: '2026-04-16', type: 'vab', hours: 4, specifikationsnummer: 2 },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
totals,
|
||||||
|
)
|
||||||
|
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">2</gem:FranvaroSpecifikationsnummer>')
|
||||||
|
expect(xml).not.toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
|
||||||
|
})
|
||||||
|
|
||||||
it('formats fractional hours with up to 2 decimals', () => {
|
it('formats fractional hours with up to 2 decimals', () => {
|
||||||
const xml = generateAGIXml(
|
const xml = generateAGIXml(
|
||||||
company,
|
company,
|
||||||
[{
|
[{
|
||||||
...employeesWithAbsence[0],
|
...employeesWithAbsence[0],
|
||||||
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 4.5 }],
|
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 4.5, specifikationsnummer: 1 }],
|
||||||
}],
|
}],
|
||||||
totals,
|
totals,
|
||||||
)
|
)
|
||||||
@@ -383,7 +405,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
|
|||||||
company,
|
company,
|
||||||
[{
|
[{
|
||||||
...employeesWithAbsence[0],
|
...employeesWithAbsence[0],
|
||||||
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 50 }],
|
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 50, specifikationsnummer: 1 }],
|
||||||
}],
|
}],
|
||||||
totals,
|
totals,
|
||||||
)
|
)
|
||||||
@@ -404,7 +426,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
|
|||||||
{ ...company, periodYear: 2025, periodMonth: 1 },
|
{ ...company, periodYear: 2025, periodMonth: 1 },
|
||||||
[{
|
[{
|
||||||
...employeesWithAbsence[0],
|
...employeesWithAbsence[0],
|
||||||
absenceEvents: [{ date: '2025-01-15', type: 'vab', hours: 8 }],
|
absenceEvents: [{ date: '2025-01-15', type: 'vab', hours: 8, specifikationsnummer: 1 }],
|
||||||
}],
|
}],
|
||||||
totals,
|
totals,
|
||||||
)
|
)
|
||||||
@@ -442,7 +464,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
|
|||||||
company,
|
company,
|
||||||
[{
|
[{
|
||||||
...employeesWithAbsence[0],
|
...employeesWithAbsence[0],
|
||||||
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 8 }],
|
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 8, specifikationsnummer: 1 }],
|
||||||
}],
|
}],
|
||||||
totals,
|
totals,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* AGI (Arbetsgivardeklaration) field codes per Skatteverket Teknisk beskrivning.
|
|
||||||
*
|
|
||||||
* FK = Fältkod (field code)
|
|
||||||
* Ruta = Box number on the form
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Huvuduppgift (Employer totals)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/** Employer-level field codes */
|
|
||||||
export const HUVUDUPPGIFT_FIELDS = {
|
|
||||||
/** Total skatteavdrag — sum of all employee tax withholdings */
|
|
||||||
RUTA_001: '001',
|
|
||||||
/** Total underlag for arbetsgivaravgifter */
|
|
||||||
RUTA_020: '020',
|
|
||||||
/** Arbetsgivaravgifter — standard rate (31.42%) */
|
|
||||||
RUTA_060: '060',
|
|
||||||
/** Arbetsgivaravgifter — age-reduced (10.21%, born ≤1959 for 2026) */
|
|
||||||
RUTA_061: '061',
|
|
||||||
/** Arbetsgivaravgifter — youth rate (20.81%, vid årets ingång 18–22 år, Apr 2026–Sep 2027; Prop. 2025/26:66) */
|
|
||||||
RUTA_062: '062',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Individuppgift (Per-employee data)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/** Per-employee field codes */
|
|
||||||
export const INDIVID_FIELDS = {
|
|
||||||
/** Personnummer/samordningsnummer (12 digits, CRITICAL: must be decrypted) */
|
|
||||||
FK215: '215',
|
|
||||||
/** Specifikationsnummer — MUST stay consistent per employee for corrections */
|
|
||||||
FK570: '570',
|
|
||||||
/** Kontant bruttolön (gross cash salary) */
|
|
||||||
RUTA_011: '011',
|
|
||||||
/** Avdragen skatt (withheld preliminary tax) */
|
|
||||||
RUTA_001: '001',
|
|
||||||
/** Förmånsvärde — bilförmån */
|
|
||||||
RUTA_012: '012',
|
|
||||||
/** Förmånsvärde — drivmedel vid bilförmån */
|
|
||||||
RUTA_013: '013',
|
|
||||||
/** Förmånsvärde — bostad */
|
|
||||||
RUTA_014: '014',
|
|
||||||
/** Förmånsvärde — kost */
|
|
||||||
RUTA_015: '015',
|
|
||||||
/** Förmånsvärde — ränta */
|
|
||||||
RUTA_016: '016',
|
|
||||||
/** Förmånsvärde — övriga */
|
|
||||||
RUTA_019: '019',
|
|
||||||
/** Underlag för arbetsgivaravgifter */
|
|
||||||
RUTA_020: '020',
|
|
||||||
/** Ersättning till mottagare med F-skattsedel (not subject to avgifter) */
|
|
||||||
RUTA_131: '131',
|
|
||||||
// Absence fields (from 2025)
|
|
||||||
/** Sjukfrånvaro — antal dagar */
|
|
||||||
FK821: '821',
|
|
||||||
/** VAB — antal dagar */
|
|
||||||
FK822: '822',
|
|
||||||
/** Föräldraledighet — antal dagar */
|
|
||||||
FK823: '823',
|
|
||||||
/** Graviditetspenning — antal dagar */
|
|
||||||
FK824: '824',
|
|
||||||
/** Smittbärarpenning — antal dagar */
|
|
||||||
FK825: '825',
|
|
||||||
/** Sjuk-/aktivitetsersättning — antal dagar */
|
|
||||||
FK826: '826',
|
|
||||||
/** Rehabilitering — antal dagar */
|
|
||||||
FK827: '827',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Benefit type to ruta mapping
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/** Map benefit item types to AGI individuppgift rutor */
|
|
||||||
export const BENEFIT_RUTA_MAP: Record<string, string> = {
|
|
||||||
benefit_car: INDIVID_FIELDS.RUTA_012,
|
|
||||||
benefit_housing: INDIVID_FIELDS.RUTA_014,
|
|
||||||
benefit_meals: INDIVID_FIELDS.RUTA_015,
|
|
||||||
benefit_wellness: INDIVID_FIELDS.RUTA_019,
|
|
||||||
benefit_bike: INDIVID_FIELDS.RUTA_019,
|
|
||||||
benefit_other: INDIVID_FIELDS.RUTA_019,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Avgifter category to ruta mapping
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export type AvgifterCategory = 'standard' | 'reduced_65plus' | 'youth' | 'vaxa_stod' | 'exempt'
|
|
||||||
|
|
||||||
/** Map avgifter categories to huvuduppgift rutor */
|
|
||||||
export const AVGIFTER_RUTA_MAP: Record<AvgifterCategory, string> = {
|
|
||||||
standard: HUVUDUPPGIFT_FIELDS.RUTA_060,
|
|
||||||
reduced_65plus: HUVUDUPPGIFT_FIELDS.RUTA_061,
|
|
||||||
youth: HUVUDUPPGIFT_FIELDS.RUTA_062,
|
|
||||||
vaxa_stod: HUVUDUPPGIFT_FIELDS.RUTA_061, // Växa-stöd uses same rate as 65+
|
|
||||||
exempt: '', // No avgifter
|
|
||||||
}
|
|
||||||
@@ -22,15 +22,64 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
generateAGIXml,
|
generateAGIXml,
|
||||||
buildIndividuppgifterSnapshot,
|
buildIndividuppgifterSnapshot,
|
||||||
AGIIncompleteDataError,
|
AGIIncompleteDataError,
|
||||||
|
AGIPayloadTooLargeError,
|
||||||
} from './xml-generator'
|
} from './xml-generator'
|
||||||
import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator'
|
import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator'
|
||||||
import { eventBus } from '@/lib/events'
|
import { eventBus } from '@/lib/events'
|
||||||
import type { Logger } from '@/lib/logger'
|
import type { Logger } from '@/lib/logger'
|
||||||
|
|
||||||
|
// Strict runtime validation of the joined salary_run_employees row. Without
|
||||||
|
// this, columns added by recent migrations (removed_from_agi,
|
||||||
|
// benefits_adjusted, vaxa_stod_eligible, employment_start,
|
||||||
|
// housing_benefit_type) reaching the mapper as null/undefined would silently
|
||||||
|
// fall back to Boolean(undefined) = false and mis-emit regulatory flags.
|
||||||
|
// Zod produces an explicit error instead.
|
||||||
|
const EmployeeJoinSchema = z
|
||||||
|
.object({
|
||||||
|
personnummer: z.string().min(1, 'employee.personnummer saknas'),
|
||||||
|
specification_number: z.number().int().min(1, 'employee.specification_number måste vara ≥ 1'),
|
||||||
|
f_skatt_status: z.string(),
|
||||||
|
monthly_salary: z.number().nullable().optional(),
|
||||||
|
vaxa_stod_eligible: z.boolean().nullable().optional(),
|
||||||
|
employment_start: z.string().nullable().optional(),
|
||||||
|
housing_benefit_type: z.enum(['smahus', 'ej_smahus']).nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
const LineItemSchema = z
|
||||||
|
.object({
|
||||||
|
item_type: z.string(),
|
||||||
|
amount: z.number().nullable().optional(),
|
||||||
|
quantity: z.number().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
const SalaryRunEmployeeRowSchema = z
|
||||||
|
.object({
|
||||||
|
employee_id: z.string().uuid(),
|
||||||
|
gross_salary: z.number(),
|
||||||
|
tax_withheld: z.number(),
|
||||||
|
avgifter_basis: z.number(),
|
||||||
|
avgifter_amount: z.number(),
|
||||||
|
avgifter_rate: z.number(),
|
||||||
|
avgifter_category: z.string().nullable().optional(),
|
||||||
|
removed_from_agi: z.boolean().nullable().optional(),
|
||||||
|
benefits_adjusted: z.boolean().nullable().optional(),
|
||||||
|
sick_days: z.number().nullable().optional(),
|
||||||
|
vab_days: z.number().nullable().optional(),
|
||||||
|
parental_days: z.number().nullable().optional(),
|
||||||
|
employee: EmployeeJoinSchema.nullable(),
|
||||||
|
line_items: z.array(LineItemSchema).nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
type SalaryRunEmployeeRow = z.infer<typeof SalaryRunEmployeeRowSchema>
|
||||||
|
|
||||||
const ELIGIBLE_STATUSES = ['review', 'approved', 'paid', 'booked', 'corrected'] as const
|
const ELIGIBLE_STATUSES = ['review', 'approved', 'paid', 'booked', 'corrected'] as const
|
||||||
|
|
||||||
export interface GenerateAgiDeclarationArgs {
|
export interface GenerateAgiDeclarationArgs {
|
||||||
@@ -124,7 +173,7 @@ export async function generateAgiDeclaration(
|
|||||||
const { data: runEmployees } = await supabase
|
const { data: runEmployees } = await supabase
|
||||||
.from('salary_run_employees')
|
.from('salary_run_employees')
|
||||||
.select(
|
.select(
|
||||||
'*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary), line_items:salary_line_items(*)',
|
'*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary, vaxa_stod_eligible, employment_start, housing_benefit_type), line_items:salary_line_items(*)',
|
||||||
)
|
)
|
||||||
.eq('salary_run_id', salaryRunId)
|
.eq('salary_run_id', salaryRunId)
|
||||||
|
|
||||||
@@ -153,12 +202,17 @@ export async function generateAgiDeclaration(
|
|||||||
|
|
||||||
const absenceByEmployee = new Map<
|
const absenceByEmployee = new Map<
|
||||||
string,
|
string,
|
||||||
Array<{ date: string; type: 'vab' | 'parental'; hours: number }>
|
Array<{
|
||||||
|
date: string
|
||||||
|
type: 'vab' | 'parental'
|
||||||
|
hours: number
|
||||||
|
specifikationsnummer: number
|
||||||
|
}>
|
||||||
>()
|
>()
|
||||||
if (employeeIds.length > 0) {
|
if (employeeIds.length > 0) {
|
||||||
const { data: absenceRows } = await supabase
|
const { data: absenceRows } = await supabase
|
||||||
.from('salary_absence_days')
|
.from('salary_absence_days')
|
||||||
.select('employee_id, absence_date, absence_type, hours')
|
.select('employee_id, absence_date, absence_type, hours, franvaro_specifikationsnummer')
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.in('absence_type', ['vab', 'parental'])
|
.in('absence_type', ['vab', 'parental'])
|
||||||
.gte('absence_date', periodStart)
|
.gte('absence_date', periodStart)
|
||||||
@@ -169,73 +223,153 @@ export async function generateAgiDeclaration(
|
|||||||
absence_date: string
|
absence_date: string
|
||||||
absence_type: 'vab' | 'parental'
|
absence_type: 'vab' | 'parental'
|
||||||
hours: number
|
hours: number
|
||||||
|
franvaro_specifikationsnummer: number | null
|
||||||
}>) {
|
}>) {
|
||||||
|
// Row should always have a number for vab/parental (trigger assigns
|
||||||
|
// on insert + backfill migration covers existing data). Defensive
|
||||||
|
// fallback: skip rows missing the number rather than emit a bogus 0,
|
||||||
|
// which would collide with Skatteverket's unique key.
|
||||||
|
if (row.franvaro_specifikationsnummer == null) continue
|
||||||
const list = absenceByEmployee.get(row.employee_id) ?? []
|
const list = absenceByEmployee.get(row.employee_id) ?? []
|
||||||
list.push({
|
list.push({
|
||||||
date: row.absence_date,
|
date: row.absence_date,
|
||||||
type: row.absence_type,
|
type: row.absence_type,
|
||||||
hours: Number(row.hours ?? 8),
|
hours: Number(row.hours ?? 8),
|
||||||
|
specifikationsnummer: row.franvaro_specifikationsnummer,
|
||||||
})
|
})
|
||||||
absenceByEmployee.set(row.employee_id, list)
|
absenceByEmployee.set(row.employee_id, list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const employeeData: AGIEmployeeData[] = (runEmployees as Array<Record<string, unknown>>).map(
|
// Validate the joined rows up-front so a malformed Supabase response
|
||||||
(sre) => {
|
// (missing column, wrong type, null specification_number, …) surfaces as
|
||||||
const emp = sre.employee as {
|
// a clean AGIIncompleteDataError instead of silently emitting wrong
|
||||||
personnummer: string
|
// flags later. See SalaryRunEmployeeRowSchema definition above.
|
||||||
specification_number: number
|
const parsedRows: SalaryRunEmployeeRow[] = (runEmployees as unknown[]).map((raw, idx) => {
|
||||||
f_skatt_status: string
|
const parsed = SalaryRunEmployeeRowSchema.safeParse(raw)
|
||||||
} | null
|
if (!parsed.success) {
|
||||||
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
|
const fields = parsed.error.issues.map((iss) => iss.path.join('.')).join(', ')
|
||||||
|
throw new AGIIncompleteDataError(
|
||||||
|
`salary_run_employees rad ${idx} har ogiltig form (saknar/felaktiga fält: ${fields}). ` +
|
||||||
|
'Detta blockerar AGI-generering eftersom Skatteverket annars skulle få bogus värden ' +
|
||||||
|
'(till exempel emitterade flaggor eller specifikationsnummer = 0).',
|
||||||
|
['salary_run_employees'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return parsed.data
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cutoff for the Växa-stöd FK062/FK063 split: pre-2024-05-01 hires get the
|
||||||
|
// legacy "första anställda"-flag (FK062); 2024-05-01 and later get the
|
||||||
|
// utvidgat växa-stöd flag (FK063). Cutoff from Skatteverket spec (Prop.
|
||||||
|
// 2023/24:80, RAML revisionshistorik 1.19).
|
||||||
|
const VAXA_STOD_FK063_CUTOFF = '2024-05-01'
|
||||||
|
|
||||||
|
const employeeData: AGIEmployeeData[] = parsedRows.map((sre) => {
|
||||||
|
const emp = sre.employee
|
||||||
|
const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }>
|
||||||
|
|
||||||
const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car'])
|
const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car'])
|
||||||
const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals'])
|
const benefitFuel = sumLineItemAmounts(lineItems, ['benefit_fuel'])
|
||||||
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
|
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
|
||||||
const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other'])
|
// FK015 kostförmån has its own field — never fold into FK012.
|
||||||
const absenceEvents = absenceByEmployee.get(sre.employee_id as string)
|
// Skatteverket cross-checks the krona-amount against the PBB-schablon.
|
||||||
|
const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals'])
|
||||||
|
// FK012 SkatteplOvrigaFormanerUlagAG is the catch-all for taxable
|
||||||
|
// benefits without their own FK code (bike, wellness, "other") PLUS
|
||||||
|
// the krona-amount for housing (since FK041/FK043 carry only the flag).
|
||||||
|
const benefitOther = sumLineItemAmounts(lineItems, [
|
||||||
|
'benefit_bike',
|
||||||
|
'benefit_wellness',
|
||||||
|
'benefit_other',
|
||||||
|
]) + benefitHousing
|
||||||
|
|
||||||
|
// Default housing type: if the employee got a housing benefit line
|
||||||
|
// item but no housing_benefit_type is set, treat as 'ej_smahus' (the
|
||||||
|
// more common case). NULL with no benefit line item → no flag emitted.
|
||||||
|
let housingBenefit: 'smahus' | 'ej_smahus' | undefined
|
||||||
|
if (benefitHousing > 0) {
|
||||||
|
housingBenefit = emp?.housing_benefit_type ?? 'ej_smahus'
|
||||||
|
}
|
||||||
|
|
||||||
|
const absenceEvents = absenceByEmployee.get(sre.employee_id)
|
||||||
|
|
||||||
|
let vaxaStod: 'forsta_anstalld' | 'vaxa_stod' | undefined
|
||||||
|
if (emp?.vaxa_stod_eligible) {
|
||||||
|
vaxaStod =
|
||||||
|
emp.employment_start && emp.employment_start < VAXA_STOD_FK063_CUTOFF
|
||||||
|
? 'forsta_anstalld'
|
||||||
|
: 'vaxa_stod'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Växa-stöd (employment-start-gated relief, 10.21 % avgifter) and the
|
||||||
|
// ungdomsrabatt (age-gated relief, 'youth' avgifter_category) are
|
||||||
|
// distinct statutory programs and must not be claimed for the same
|
||||||
|
// employee in the same period. Catching this at generation time
|
||||||
|
// avoids emitting an FK062/FK063 flag inconsistent with the FK061
|
||||||
|
// category total.
|
||||||
|
if (vaxaStod && sre.avgifter_category === 'youth') {
|
||||||
|
throw new AGIIncompleteDataError(
|
||||||
|
`Anställd ${emp?.specification_number ?? '?'}: kan inte kombinera växa-stöd ` +
|
||||||
|
'(FK062/FK063) med ungdomsrabatt (avgifter_category="youth") — programmen är ömsesidigt uteslutande. ' +
|
||||||
|
'Välj ett av dem under anställdas inställningar.',
|
||||||
|
['vaxa_stod_eligible', 'avgifter_category'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFSkatt = emp?.f_skatt_status === 'f_skatt'
|
||||||
return {
|
return {
|
||||||
personnummer: emp?.personnummer || '',
|
personnummer: emp?.personnummer ?? '',
|
||||||
specificationNumber: emp?.specification_number || 0,
|
specificationNumber: emp?.specification_number ?? 0,
|
||||||
grossSalary: sre.gross_salary as number,
|
removed: Boolean(sre.removed_from_agi),
|
||||||
taxWithheld: sre.tax_withheld as number,
|
grossSalary: sre.gross_salary,
|
||||||
avgifterBasis: sre.avgifter_basis as number,
|
taxWithheld: sre.tax_withheld,
|
||||||
fSkattPayment:
|
avgifterBasis: sre.avgifter_basis,
|
||||||
emp?.f_skatt_status === 'f_skatt' ? (sre.gross_salary as number) : undefined,
|
fSkattPayment: isFSkatt ? sre.gross_salary : undefined,
|
||||||
|
// F-skatt payees: cash goes to FK131 and benefits to the ej-UlagSA
|
||||||
|
// variants (FK132/FK133/FK134/FK137/FK138/FK139). Regular employees
|
||||||
|
// get FK011 + FK012/FK013/FK015/FK018/FK041/FK043.
|
||||||
|
benefitsExcludedFromSAUnderlag: isFSkatt ? true : undefined,
|
||||||
benefitCar: benefitCar > 0 ? benefitCar : undefined,
|
benefitCar: benefitCar > 0 ? benefitCar : undefined,
|
||||||
benefitHousing: benefitHousing > 0 ? benefitHousing : undefined,
|
benefitFuel: benefitFuel > 0 ? benefitFuel : undefined,
|
||||||
benefitMeals: benefitMeals > 0 ? benefitMeals : undefined,
|
benefitMeals: benefitMeals > 0 ? benefitMeals : undefined,
|
||||||
|
housingBenefit,
|
||||||
benefitOther: benefitOther > 0 ? benefitOther : undefined,
|
benefitOther: benefitOther > 0 ? benefitOther : undefined,
|
||||||
sickDays: (sre.sick_days as number) > 0 ? (sre.sick_days as number) : undefined,
|
benefitsAdjusted: Boolean(sre.benefits_adjusted),
|
||||||
vabDays: (sre.vab_days as number) > 0 ? (sre.vab_days as number) : undefined,
|
vaxaStod,
|
||||||
|
sickDays: (sre.sick_days ?? 0) > 0 ? (sre.sick_days ?? 0) : undefined,
|
||||||
|
vabDays: (sre.vab_days ?? 0) > 0 ? (sre.vab_days ?? 0) : undefined,
|
||||||
parentalDays:
|
parentalDays:
|
||||||
(sre.parental_days as number) > 0 ? (sre.parental_days as number) : undefined,
|
(sre.parental_days ?? 0) > 0 ? (sre.parental_days ?? 0) : undefined,
|
||||||
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
|
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs).
|
// 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs).
|
||||||
|
// Removed-from-AGI rows (FK205 borttag) are tombstones — they must not
|
||||||
|
// contribute to FK497/FK487/FK499 because the prior submission's amounts
|
||||||
|
// remain on file at Skatteverket; the borttag just removes the IU itself.
|
||||||
|
const activeEmployees = parsedRows.filter((sre) => !sre.removed_from_agi)
|
||||||
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
|
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
|
||||||
for (const sre of runEmployees as Array<Record<string, unknown>>) {
|
for (const sre of activeEmployees) {
|
||||||
const dbCategory = sre.avgifter_category as string | null
|
const dbCategory = sre.avgifter_category ?? null
|
||||||
const category = dbCategory
|
const category = dbCategory
|
||||||
? dbCategory === 'reduced_65plus'
|
? dbCategory === 'reduced_65plus'
|
||||||
? 'reduced65plus'
|
? 'reduced65plus'
|
||||||
: dbCategory === 'vaxa_stod'
|
: dbCategory === 'vaxa_stod'
|
||||||
? 'standard'
|
? 'standard'
|
||||||
: dbCategory
|
: dbCategory
|
||||||
: (sre.avgifter_rate as number) <= 0.1022
|
: sre.avgifter_rate <= 0.1022
|
||||||
? 'reduced65plus'
|
? 'reduced65plus'
|
||||||
: (sre.avgifter_rate as number) <= 0.2082
|
: sre.avgifter_rate <= 0.2082
|
||||||
? 'youth'
|
? 'youth'
|
||||||
: 'standard'
|
: 'standard'
|
||||||
const cat = (avgifterByCategory as Record<string, { basis: number; amount: number }>)[
|
const cat = (avgifterByCategory as Record<string, { basis: number; amount: number }>)[
|
||||||
category
|
category
|
||||||
] || { basis: 0, amount: 0 }
|
] || { basis: 0, amount: 0 }
|
||||||
cat.basis += sre.avgifter_basis as number
|
cat.basis += sre.avgifter_basis
|
||||||
cat.amount += sre.avgifter_amount as number
|
cat.amount += sre.avgifter_amount
|
||||||
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
|
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
|
||||||
}
|
}
|
||||||
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
|
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
|
||||||
@@ -251,24 +385,30 @@ export async function generateAgiDeclaration(
|
|||||||
}
|
}
|
||||||
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8
|
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8
|
||||||
let totalSjuklonekostnad = 0
|
let totalSjuklonekostnad = 0
|
||||||
for (const sre of runEmployees as Array<Record<string, unknown>>) {
|
for (const sre of activeEmployees) {
|
||||||
const monthly =
|
const monthly = sre.employee?.monthly_salary ?? 0
|
||||||
((sre.employee as { monthly_salary?: number } | null)?.monthly_salary as number) ?? 0
|
|
||||||
if (!monthly) continue
|
if (!monthly) continue
|
||||||
const dailyRate = monthly / 21
|
const dailyRate = monthly / 21
|
||||||
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
|
const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }>
|
||||||
for (const li of lineItems) {
|
for (const li of lineItems) {
|
||||||
if (li.item_type === 'sick_day2_14') {
|
if (li.item_type === 'sick_day2_14') {
|
||||||
const days = (li.quantity as number) || 0
|
const days = li.quantity ?? 0
|
||||||
totalSjuklonekostnad += dailyRate * sjuklonRate * days
|
totalSjuklonekostnad += dailyRate * sjuklonRate * days
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FK497 SummaSkatteavdr must equal the sum of FK001 on active IUs (not
|
||||||
|
// run.total_tax, which includes removed rows). Same for FK487.
|
||||||
|
const totalTax = activeEmployees.reduce(
|
||||||
|
(sum, sre) => sum + (sre.tax_withheld || 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
const totals: AGITotals = {
|
const totals: AGITotals = {
|
||||||
totalTax: run.total_tax,
|
totalTax: Math.round(totalTax * 100) / 100,
|
||||||
totalAvgifterBasis: (runEmployees as Array<{ avgifter_basis: number }>).reduce(
|
totalAvgifterBasis: activeEmployees.reduce(
|
||||||
(s, e) => s + e.avgifter_basis,
|
(s, e) => s + (e.avgifter_basis || 0),
|
||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
|
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
|
||||||
@@ -276,6 +416,31 @@ export async function generateAgiDeclaration(
|
|||||||
avgifterByCategory,
|
avgifterByCategory,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soft AGI deadline check: warn (but don't block) when generating for a
|
||||||
|
// future period or one whose Skatteverket correction window is clearly
|
||||||
|
// past. Filing deadline is the 12th (17th in Jan/Aug for small employers)
|
||||||
|
// of the month after the period; SKV accepts corrections for a long time
|
||||||
|
// after, but a period > 13 months in the past is almost certainly a
|
||||||
|
// misclick. Surface via the logger so audit log + Sentry both see it.
|
||||||
|
{
|
||||||
|
const now = new Date()
|
||||||
|
const currentYM = now.getUTCFullYear() * 100 + (now.getUTCMonth() + 1)
|
||||||
|
const periodYM = run.period_year * 100 + run.period_month
|
||||||
|
if (periodYM > currentYM) {
|
||||||
|
opLog.warn('AGI generated for future period', {
|
||||||
|
companyId,
|
||||||
|
periodYear: run.period_year,
|
||||||
|
periodMonth: run.period_month,
|
||||||
|
})
|
||||||
|
} else if (currentYM - periodYM > 13) {
|
||||||
|
opLog.warn('AGI generated for period > 13 months past', {
|
||||||
|
companyId,
|
||||||
|
periodYear: run.period_year,
|
||||||
|
periodMonth: run.period_month,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Existing AGI determines correction status. Use `.maybeSingle()`
|
// 6. Existing AGI determines correction status. Use `.maybeSingle()`
|
||||||
// because the lookup must tolerate the no-row case without throwing —
|
// because the lookup must tolerate the no-row case without throwing —
|
||||||
// that's the FIRST-time generation path. `.single()` would surface a
|
// that's the FIRST-time generation path. `.single()` would surface a
|
||||||
@@ -302,6 +467,18 @@ export async function generateAgiDeclaration(
|
|||||||
details: { missing_fields: err.missingFields, message: err.message },
|
details: { missing_fields: err.missingFields, message: err.message },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (err instanceof AGIPayloadTooLargeError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'AGI_PAYLOAD_TOO_LARGE',
|
||||||
|
details: {
|
||||||
|
message: err.message,
|
||||||
|
size_bytes: err.sizeBytes,
|
||||||
|
limit_bytes: err.limitBytes,
|
||||||
|
},
|
||||||
|
status: 413,
|
||||||
|
}
|
||||||
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
const individuppgifter = buildIndividuppgifterSnapshot(employeeData)
|
const individuppgifter = buildIndividuppgifterSnapshot(employeeData)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schemas for Skatteverket AGI pre-flight kontrollera endpoints.
|
||||||
|
* Matches the v1.7 §7 (HU) and §8 (IU) JSON spec exactly, with strict()
|
||||||
|
* to reject unknown properties — without this guard, a caller could inject
|
||||||
|
* fields like agRegistreradId or personnummer overrides into the payload
|
||||||
|
* we forward verbatim to SKV.
|
||||||
|
*
|
||||||
|
* Conventions used by Skatteverket:
|
||||||
|
* - IDENTITET: 12 digits (orgnr prefixed "16" + 10 digits, or personnummer YYYYMMDDXXXX)
|
||||||
|
* - redovisningsPeriod: YYYYMM
|
||||||
|
* - amount fields: integer SEK (no decimals)
|
||||||
|
* - boolean kryss fields: true/false (JSON) — SKV converts to <FK>1</FK> in XML
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 12-digit IDENTITET pattern. Slightly looser than the XSD regex used in
|
||||||
|
// xml-generator.ts (we don't re-validate samordningsnummer arithmetic here)
|
||||||
|
// because SKV will reject malformed values on its end with a clearer
|
||||||
|
// felmeddelande than we can surface — what matters here is that we don't
|
||||||
|
// forward an obviously bogus or oversized string.
|
||||||
|
const IDENTITET = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{12}$/, 'Förväntat 12-siffrigt IDENTITET (orgnr eller personnummer).')
|
||||||
|
|
||||||
|
const REDOVISNINGSPERIOD = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{6}$/, 'Förväntat YYYYMM.')
|
||||||
|
// First period the API accepts; same constraint as xml-generator.
|
||||||
|
.refine((s) => Number.parseInt(s, 10) >= 201807, {
|
||||||
|
message: 'Redovisningsperioden är tidigare än 201807 (AGI API minimum).',
|
||||||
|
})
|
||||||
|
|
||||||
|
// SEK amount as non-negative integer with a sanity cap matching SKV's
|
||||||
|
// internal BELOPP10 (10-digit) ceiling. Bigger values are rejected locally
|
||||||
|
// rather than forwarded.
|
||||||
|
const AMOUNT = z.number().int().nonnegative().max(9_999_999_999)
|
||||||
|
|
||||||
|
const SPEC_NUMBER = z.number().int().min(1).max(999_999_999)
|
||||||
|
|
||||||
|
export const AGIKontrolleraHUSchema = z
|
||||||
|
.object({
|
||||||
|
agRegistreradId: IDENTITET,
|
||||||
|
redovisningsPeriod: REDOVISNINGSPERIOD,
|
||||||
|
summaSkatteavdr: AMOUNT.optional(),
|
||||||
|
summaArbAvgSlf: AMOUNT.optional(),
|
||||||
|
totalSjuklonekostnad: AMOUNT.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type AGIKontrolleraHU = z.infer<typeof AGIKontrolleraHUSchema>
|
||||||
|
|
||||||
|
export const AGIKontrolleraIUSchema = z
|
||||||
|
.object({
|
||||||
|
agRegistreradId: IDENTITET,
|
||||||
|
redovisningsPeriod: REDOVISNINGSPERIOD,
|
||||||
|
betalningsmottagarId: IDENTITET,
|
||||||
|
specifikationsnummer: SPEC_NUMBER,
|
||||||
|
|
||||||
|
// Cash + tax — FK011 / FK001
|
||||||
|
kontantErsattningUlagAG: AMOUNT.optional(),
|
||||||
|
avdrPrelSkatt: AMOUNT.optional(),
|
||||||
|
|
||||||
|
// Benefit amounts (UlagAG variants)
|
||||||
|
skatteplBilformanUlagAG: AMOUNT.optional(), // FK013
|
||||||
|
drivmVidBilformanUlagAG: AMOUNT.optional(), // FK018
|
||||||
|
kostformanUlagAG: AMOUNT.optional(), // FK015
|
||||||
|
skatteplOvrigaFormanerUlagAG: AMOUNT.optional(), // FK012
|
||||||
|
|
||||||
|
// Housing benefit KRYSS flags
|
||||||
|
bostadsformanSmahusUlagAG: z.boolean().optional(), // FK041
|
||||||
|
bostadsformanEjSmahusUlagAG: z.boolean().optional(), // FK043
|
||||||
|
|
||||||
|
// F-skatt / ej UlagSA variants
|
||||||
|
kontantErsattningEjUlagSA: AMOUNT.optional(), // FK131
|
||||||
|
skatteplBilformanEjUlagSA: AMOUNT.optional(), // FK133
|
||||||
|
drivmVidBilformanEjUlagSA: AMOUNT.optional(), // FK134
|
||||||
|
kostformanEjUlagSA: AMOUNT.optional(), // FK139
|
||||||
|
skatteplOvrigaFormanerEjUlagSA: AMOUNT.optional(), // FK132
|
||||||
|
bostadsformanSmahusEjUlagSA: z.boolean().optional(), // FK137
|
||||||
|
bostadsformanEjSmahusEjUlagSA: z.boolean().optional(),// FK138
|
||||||
|
|
||||||
|
// Flags
|
||||||
|
formanHarJusterats: z.boolean().optional(), // FK048
|
||||||
|
forstaAnstalld: z.boolean().optional(), // FK062
|
||||||
|
vaxaStod: z.boolean().optional(), // FK063
|
||||||
|
borttag: z.boolean().optional(), // FK205
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(iu) => !(iu.forstaAnstalld === true && iu.vaxaStod === true),
|
||||||
|
{
|
||||||
|
message: 'FK062 (ForstaAnstalld) och FK063 (VaxaStod) är ömsesidigt uteslutande.',
|
||||||
|
path: ['vaxaStod'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type AGIKontrolleraIU = z.infer<typeof AGIKontrolleraIUSchema>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard cap on the raw JSON body for kontrollera endpoints. Even a fully
|
||||||
|
* legal IU is < 4 KB serialised; 64 KB is a generous safety margin that
|
||||||
|
* still trivially rejects pathological inputs before they reach Zod.
|
||||||
|
*/
|
||||||
|
export const AGI_KONTROLLERA_MAX_BYTES = 64 * 1024
|
||||||
+260
-45
@@ -30,13 +30,14 @@ import { getBranding } from '@/lib/branding/service'
|
|||||||
* - Hours emitted via FranvaroTimmarTFP (FK825) for VAB or FranvaroTimmarFP
|
* - Hours emitted via FranvaroTimmarTFP (FK825) for VAB or FranvaroTimmarFP
|
||||||
* (FK827) for parental. The procent variants (824/826) are not used —
|
* (FK827) for parental. The procent variants (824/826) are not used —
|
||||||
* gnubok tracks hours, not percent.
|
* gnubok tracks hours, not percent.
|
||||||
* - FranvaroSpecifikationsnummer is assigned 1-based per (employee, period),
|
* - FranvaroSpecifikationsnummer is persisted on salary_absence_days
|
||||||
* ordered by date. Skatteverket replaces a Frånvarouppgift on match of
|
* (column franvaro_specifikationsnummer; assigned by DB trigger on
|
||||||
* (BetalningsmottagarId, FranvaroDatum, FranvaroSpecifikationsnummer,
|
* INSERT, never re-numbered). Skatteverket replaces a Frånvarouppgift
|
||||||
* RedovisningsPeriod, AgRegistreradId) — for stable replacement across
|
* on match of (BetalningsmottagarId, FranvaroDatum,
|
||||||
* re-generations the numbering must persist; if dates are added/removed
|
* FranvaroSpecifikationsnummer, RedovisningsPeriod, AgRegistreradId).
|
||||||
* mid-period the indices shift. First-submit is fine; correction
|
* Because the number is stable, corrections survive day deletions:
|
||||||
* stability is a follow-up TODO (persist event → number mapping).
|
* remaining events keep their original numbers, and Skatteverket
|
||||||
|
* matches each event back to its prior submission.
|
||||||
* - Periods before 202501 emit no Frånvarouppgift (Skatteverket rejects).
|
* - Periods before 202501 emit no Frånvarouppgift (Skatteverket rejects).
|
||||||
*
|
*
|
||||||
* Per-employee sick days are NOT reported via AGI under any version — they
|
* Per-employee sick days are NOT reported via AGI under any version — they
|
||||||
@@ -62,6 +63,14 @@ export interface AGIAbsenceEvent {
|
|||||||
type: 'vab' | 'parental'
|
type: 'vab' | 'parental'
|
||||||
/** Hours absent on this date, 0.01–24.00. Defaults to 8 in salary_absence_days. */
|
/** Hours absent on this date, 0.01–24.00. Defaults to 8 in salary_absence_days. */
|
||||||
hours: number
|
hours: number
|
||||||
|
/**
|
||||||
|
* FK822 FranvaroSpecifikationsnummer — stable per-(employee, year-month)
|
||||||
|
* sequence assigned at the DB level (see migration
|
||||||
|
* 20260517120000_salary_absence_days_franvaro_specifikationsnummer.sql).
|
||||||
|
* MUST stay constant across corrections — never recompute from array
|
||||||
|
* index. Persisted on salary_absence_days.franvaro_specifikationsnummer.
|
||||||
|
*/
|
||||||
|
specifikationsnummer: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AGIEmployeeData {
|
export interface AGIEmployeeData {
|
||||||
@@ -70,13 +79,65 @@ export interface AGIEmployeeData {
|
|||||||
grossSalary: number // FK011 KontantErsattningUlagAG
|
grossSalary: number // FK011 KontantErsattningUlagAG
|
||||||
taxWithheld: number // FK001 AvdrPrelSkatt
|
taxWithheld: number // FK001 AvdrPrelSkatt
|
||||||
avgifterBasis: number // Retained for backwards compat; equals grossSalary for standard cases. Not emitted separately (FK011 already captures basis).
|
avgifterBasis: number // Retained for backwards compat; equals grossSalary for standard cases. Not emitted separately (FK011 already captures basis).
|
||||||
|
/**
|
||||||
|
* FK205 Borttag — tombstone this IU. When true, the XML emits only the
|
||||||
|
* identity fields (FK201, FK215, FK570, FK006) plus <Borttag>1</Borttag>;
|
||||||
|
* amounts and benefits are skipped. Skatteverket then removes the prior
|
||||||
|
* IU matching (AgRegistreradId, BetalningsmottagarId, RedovisningsPeriod,
|
||||||
|
* Specifikationsnummer). Only meaningful for periods that already had an
|
||||||
|
* AGI declaration filed.
|
||||||
|
*/
|
||||||
|
removed?: boolean
|
||||||
|
/**
|
||||||
|
* Växa-stöd flag — emitted as one of two mutually exclusive boolean fields:
|
||||||
|
* 'forsta_anstalld' → FK062 ForstaAnstalld (anställd före 2024-05-01)
|
||||||
|
* 'vaxa_stod' → FK063 VaxaStod (anställd efter 2024-04-30)
|
||||||
|
* Set when the employer claims växa-stöd reduction (10.21% avgifter rate)
|
||||||
|
* for this employee in the period. The cutoff date is hard-coded in the
|
||||||
|
* spec (Prop. 2023/24:80, see Skatteverket FK 1.7 revisionshistorik 1.19).
|
||||||
|
*/
|
||||||
|
vaxaStod?: 'forsta_anstalld' | 'vaxa_stod'
|
||||||
|
/**
|
||||||
|
* FK048 FormanHarJusterats — set when any benefit value on this IU has
|
||||||
|
* been adjusted away from the standard schablon. Reflects
|
||||||
|
* salary_run_employees.benefits_adjusted.
|
||||||
|
*/
|
||||||
|
benefitsAdjusted?: boolean
|
||||||
fSkattPayment?: number // FK131 KontantErsattningEjUlagSA
|
fSkattPayment?: number // FK131 KontantErsattningEjUlagSA
|
||||||
benefitCar?: number // FK013 SkatteplBilformanUlagAG
|
benefitCar?: number // FK013 SkatteplBilformanUlagAG (amount, BELOPP7)
|
||||||
benefitFuel?: number // FK018 DrivmVidBilformanUlagAG
|
benefitFuel?: number // FK018 DrivmVidBilformanUlagAG (amount, BELOPP7)
|
||||||
benefitHousing?: number // FK043 BostadsformanEjSmahusUlagAG (non-småhus default)
|
/**
|
||||||
benefitOther?: number // FK012 SkatteplOvrigaFormanerUlagAG
|
* FK015 KostformanUlagAG (amount, BELOPP10). Kostförmån has its own
|
||||||
/** @deprecated Meal benefit element name not verified against schema; kept for snapshot compatibility only (not emitted). */
|
* dedicated field in the AGI spec with a PBB-linked schablon value —
|
||||||
|
* Skatteverket cross-checks the reported amount against the schablon.
|
||||||
|
* Aggregating meals into FK012 (övriga förmåner) triggers automated
|
||||||
|
* discrepancy notices. Always emit FK015 separately when > 0.
|
||||||
|
*/
|
||||||
benefitMeals?: number
|
benefitMeals?: number
|
||||||
|
/**
|
||||||
|
* Housing benefit indicator. FK041 (smahus) and FK043 (ej_smahus) are
|
||||||
|
* boolean KRYSS flags in the XSD — they just signal that this kind of
|
||||||
|
* benefit was given. The AMOUNT must be folded into benefitOther
|
||||||
|
* (FK012). Pass 'smahus' or 'ej_smahus' to set the flag; omit if no
|
||||||
|
* housing benefit applies.
|
||||||
|
*/
|
||||||
|
housingBenefit?: 'smahus' | 'ej_smahus'
|
||||||
|
/**
|
||||||
|
* FK012 SkatteplOvrigaFormanerUlagAG (amount, BELOPP10). Catch-all for
|
||||||
|
* taxable benefits without their own dedicated FK code — bike, wellness,
|
||||||
|
* "other", AND the full krona-amount for housing (since FK041/FK043
|
||||||
|
* carry only the flag). Meals go in benefitMeals (FK015), NOT here.
|
||||||
|
*/
|
||||||
|
benefitOther?: number
|
||||||
|
/**
|
||||||
|
* When true, benefit amounts and housing flags emit as the "ej underlag
|
||||||
|
* SA" variants (FK132/FK133/FK134/FK137/FK138) instead of the standard
|
||||||
|
* UlagAG variants (FK012/FK013/FK018/FK041/FK043). Set this for F-skatt
|
||||||
|
* holders and other payees whose benefits should not form basis for
|
||||||
|
* arbetsgivaravgifter. Defaults to false. FK131 (cash, ej UlagSA) is
|
||||||
|
* controlled separately via fSkattPayment.
|
||||||
|
*/
|
||||||
|
benefitsExcludedFromSAUnderlag?: boolean
|
||||||
/** @deprecated Per-employee sick days are not reported via AGI (goes to Försäkringskassan separately). Kept for snapshot compatibility. */
|
/** @deprecated Per-employee sick days are not reported via AGI (goes to Försäkringskassan separately). Kept for snapshot compatibility. */
|
||||||
sickDays?: number
|
sickDays?: number
|
||||||
/** @deprecated VAB is reported via top-level <Franvarouppgift> as per-event records (see absenceEvents), not as an IU day count. Kept for snapshot compatibility. */
|
/** @deprecated VAB is reported via top-level <Franvarouppgift> as per-event records (see absenceEvents), not as an IU day count. Kept for snapshot compatibility. */
|
||||||
@@ -148,6 +209,88 @@ function assertRequiredCompanyData(company: AGICompanyData): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smallest period the AGI API accepts, per Skatteverket v1.7 spec §6.3:
|
||||||
|
* "redovisningsperiod (URI-parameter) — YYYYMM, tidigast 201807".
|
||||||
|
* Periods before this raise HTTP 404 felkod 31 at SKV's gateway.
|
||||||
|
*/
|
||||||
|
const AGI_MIN_PERIOD_YYYYMM = 201807
|
||||||
|
|
||||||
|
function assertRequiredPeriod(year: number, month: number): void {
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
|
||||||
|
throw new AGIIncompleteDataError(
|
||||||
|
`Ogiltig redovisningsperiod: ${year}-${month}. Ange ett giltigt år och månad (1–12).`,
|
||||||
|
['redovisningsperiod'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const yyyymm = year * 100 + month
|
||||||
|
if (yyyymm < AGI_MIN_PERIOD_YYYYMM) {
|
||||||
|
throw new AGIIncompleteDataError(
|
||||||
|
`Redovisningsperioden ${year}-${String(month).padStart(2, '0')} är tidigare än ` +
|
||||||
|
`${Math.floor(AGI_MIN_PERIOD_YYYYMM / 100)}-${String(AGI_MIN_PERIOD_YYYYMM % 100).padStart(2, '0')}, ` +
|
||||||
|
'som är den tidigaste period Skatteverkets AGI-API accepterar (Tjänstebeskrivning v1.7 §6.3). ' +
|
||||||
|
'Kontrollera att lönekörningens period är korrekt.',
|
||||||
|
['redovisningsperiod'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File-size ceilings from v1.7 §1: 100 MB on the test environment,
|
||||||
|
* 300 MB in production. Rejecting locally just means a cleaner Swedish
|
||||||
|
* error than the 413 felkod 27 SKV would otherwise return.
|
||||||
|
*/
|
||||||
|
const AGI_TEST_MAX_BYTES = 100 * 1024 * 1024
|
||||||
|
const AGI_PROD_MAX_BYTES = 300 * 1024 * 1024
|
||||||
|
|
||||||
|
export class AGIPayloadTooLargeError extends Error {
|
||||||
|
constructor(message: string, public readonly sizeBytes: number, public readonly limitBytes: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'AGIPayloadTooLargeError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Skatteverket environment from a dedicated env var. Default
|
||||||
|
* to the stricter 'test' bucket when unset/unrecognised — a missing or
|
||||||
|
* misconfigured value must never silently raise the size ceiling.
|
||||||
|
*
|
||||||
|
* Documented in deployment runbook; substring-matching the API URL is
|
||||||
|
* forbidden (a misconfigured URL containing 'api.test.skatteverket.se'
|
||||||
|
* would otherwise lower the limit on a production tenant — the inverse
|
||||||
|
* was equally bad).
|
||||||
|
*/
|
||||||
|
function resolveSkatteverketEnv(): 'test' | 'production' {
|
||||||
|
const raw = process.env.SKATTEVERKET_ENV?.trim().toLowerCase()
|
||||||
|
if (raw === 'production' || raw === 'prod') return 'production'
|
||||||
|
if (raw === 'test') return 'test'
|
||||||
|
if (raw && raw !== '') {
|
||||||
|
// Unrecognised value — fail closed to test. Logged once so deployments
|
||||||
|
// catch typos in CI rather than at audit time.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`SKATTEVERKET_ENV='${raw}' is not 'test' | 'production'; defaulting to 'test' (stricter limits).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return 'test'
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPayloadSize(xml: string): void {
|
||||||
|
const bytes = Buffer.byteLength(xml, 'utf8')
|
||||||
|
const env = resolveSkatteverketEnv()
|
||||||
|
const envLimit = env === 'production' ? AGI_PROD_MAX_BYTES : AGI_TEST_MAX_BYTES
|
||||||
|
if (bytes > envLimit) {
|
||||||
|
const mb = (bytes / (1024 * 1024)).toFixed(1)
|
||||||
|
const limitMb = Math.floor(envLimit / (1024 * 1024))
|
||||||
|
throw new AGIPayloadTooLargeError(
|
||||||
|
`AGI XML är för stort (${mb} MB). Skatteverkets gräns för denna miljö är ${limitMb} MB ` +
|
||||||
|
'(Tjänstebeskrivning v1.7 §1). Dela upp inlämningen i mindre paket per arbetsgivare eller period.',
|
||||||
|
bytes,
|
||||||
|
envLimit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skatteverket's IDENTITET pattern (from the AGI XSD). Accepts:
|
* Skatteverket's IDENTITET pattern (from the AGI XSD). Accepts:
|
||||||
* - 12-digit personnummer YYYYMMDDXXXX (real dates 19xx/20xx, incl. leap days
|
* - 12-digit personnummer YYYYMMDDXXXX (real dates 19xx/20xx, incl. leap days
|
||||||
@@ -211,6 +354,7 @@ export function generateAGIXml(
|
|||||||
_isCorrection: boolean = false
|
_isCorrection: boolean = false
|
||||||
): string {
|
): string {
|
||||||
assertRequiredCompanyData(company)
|
assertRequiredCompanyData(company)
|
||||||
|
assertRequiredPeriod(company.periodYear, company.periodMonth)
|
||||||
|
|
||||||
const orgIdentitet = toIdentitet(company.orgNumber)
|
const orgIdentitet = toIdentitet(company.orgNumber)
|
||||||
const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}`
|
const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}`
|
||||||
@@ -285,6 +429,19 @@ export function generateAGIXml(
|
|||||||
|
|
||||||
// ── Blankett: Individuppgift (one per employee) ──────────────
|
// ── Blankett: Individuppgift (one per employee) ──────────────
|
||||||
for (const emp of employees) {
|
for (const emp of employees) {
|
||||||
|
// FK570 must be ≥ 1 (HELTAL, min 1 per spec). A 0 here would produce a
|
||||||
|
// STOPP-level rejection at Skatteverket; fail fast with a clearer
|
||||||
|
// Swedish message pointing at the missing column rather than letting
|
||||||
|
// bogus XML reach SKV.
|
||||||
|
if (!Number.isInteger(emp.specificationNumber) || emp.specificationNumber < 1) {
|
||||||
|
throw new AGIIncompleteDataError(
|
||||||
|
`Anställd saknar giltigt specifikationsnummer (FK570). ` +
|
||||||
|
'Specifikationsnumret måste vara ett heltal ≥ 1 och stabilt över korrigeringar. ' +
|
||||||
|
'Kontrollera fältet specification_number på den anställdes profil.',
|
||||||
|
['specifikationsnummer'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let pnr: string
|
let pnr: string
|
||||||
try {
|
try {
|
||||||
pnr = decryptPersonnummer(emp.personnummer)
|
pnr = decryptPersonnummer(emp.personnummer)
|
||||||
@@ -316,6 +473,17 @@ export function generateAGIXml(
|
|||||||
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
|
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
|
||||||
lines.push(` <gem:Specifikationsnummer faltkod="570">${emp.specificationNumber}</gem:Specifikationsnummer>`)
|
lines.push(` <gem:Specifikationsnummer faltkod="570">${emp.specificationNumber}</gem:Specifikationsnummer>`)
|
||||||
|
|
||||||
|
// FK205 Borttag — tombstone this IU. When set, skip all amount/benefit
|
||||||
|
// fields; only the identity quintuple above (FK201, FK215, FK006, FK570)
|
||||||
|
// plus this flag are needed for Skatteverket to remove the prior IU.
|
||||||
|
if (emp.removed) {
|
||||||
|
lines.push(' <gem:Borttag faltkod="205">1</gem:Borttag>')
|
||||||
|
lines.push(' </gem:IU>')
|
||||||
|
lines.push(' </gem:Blankettinnehall>')
|
||||||
|
lines.push(' </gem:Blankett>')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// FK011 — Kontant ersättning, underlag arbetsgivaravgifter (= gross salary)
|
// FK011 — Kontant ersättning, underlag arbetsgivaravgifter (= gross salary)
|
||||||
if (emp.grossSalary > 0) {
|
if (emp.grossSalary > 0) {
|
||||||
lines.push(` <gem:KontantErsattningUlagAG faltkod="011">${formatAmount(emp.grossSalary)}</gem:KontantErsattningUlagAG>`)
|
lines.push(` <gem:KontantErsattningUlagAG faltkod="011">${formatAmount(emp.grossSalary)}</gem:KontantErsattningUlagAG>`)
|
||||||
@@ -326,36 +494,73 @@ export function generateAGIXml(
|
|||||||
lines.push(` <gem:AvdrPrelSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</gem:AvdrPrelSkatt>`)
|
lines.push(` <gem:AvdrPrelSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</gem:AvdrPrelSkatt>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FK013 — Bilförmån (skattepliktig, underlag AG)
|
const exclSA = emp.benefitsExcludedFromSAUnderlag === true
|
||||||
|
|
||||||
|
// Car benefit AMOUNT: FK013 (UlagAG) or FK133 (ej UlagSA)
|
||||||
if (emp.benefitCar && emp.benefitCar > 0) {
|
if (emp.benefitCar && emp.benefitCar > 0) {
|
||||||
lines.push(` <gem:SkatteplBilformanUlagAG faltkod="013">${formatAmount(emp.benefitCar)}</gem:SkatteplBilformanUlagAG>`)
|
const code = exclSA ? '133' : '013'
|
||||||
|
const elem = exclSA ? 'SkatteplBilformanEjUlagSA' : 'SkatteplBilformanUlagAG'
|
||||||
|
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitCar)}</gem:${elem}>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FK018 — Drivmedel vid bilförmån
|
// Fuel for car benefit AMOUNT: FK018 (UlagAG) or FK134 (ej UlagSA)
|
||||||
if (emp.benefitFuel && emp.benefitFuel > 0) {
|
if (emp.benefitFuel && emp.benefitFuel > 0) {
|
||||||
lines.push(` <gem:DrivmVidBilformanUlagAG faltkod="018">${formatAmount(emp.benefitFuel)}</gem:DrivmVidBilformanUlagAG>`)
|
const code = exclSA ? '134' : '018'
|
||||||
|
const elem = exclSA ? 'DrivmVidBilformanEjUlagSA' : 'DrivmVidBilformanUlagAG'
|
||||||
|
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitFuel)}</gem:${elem}>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FK043 — Bostadsförmån (ej småhus). TODO: for single-family home use
|
// Kostförmån AMOUNT: FK015 (UlagAG) or FK139 (ej UlagSA). Has its own
|
||||||
// BostadsformanSmahusUlagAG (FK041); currently defaults to non-småhus.
|
// field because Skatteverket cross-checks the krona-belopp against the
|
||||||
if (emp.benefitHousing && emp.benefitHousing > 0) {
|
// PBB-anchored schablon — folding it into FK012 triggers discrepancy
|
||||||
lines.push(` <gem:BostadsformanEjSmahusUlagAG faltkod="043">${formatAmount(emp.benefitHousing)}</gem:BostadsformanEjSmahusUlagAG>`)
|
// notices. Always emit separately when > 0.
|
||||||
|
if (emp.benefitMeals && emp.benefitMeals > 0) {
|
||||||
|
const code = exclSA ? '139' : '015'
|
||||||
|
const elem = exclSA ? 'KostformanEjUlagSA' : 'KostformanUlagAG'
|
||||||
|
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitMeals)}</gem:${elem}>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FK012 — Övriga skattepliktiga förmåner
|
// Housing benefit FLAGS (KRYSS, no amount on this element). The
|
||||||
|
// krona-amount belongs in benefitOther (FK012/FK132).
|
||||||
|
// FK041 BostadsformanSmahusUlagAG | FK137 BostadsformanSmahusEjUlagSA
|
||||||
|
// FK043 BostadsformanEjSmahusUlagAG | FK138 BostadsformanEjSmahusEjUlagSA
|
||||||
|
if (emp.housingBenefit === 'smahus') {
|
||||||
|
const code = exclSA ? '137' : '041'
|
||||||
|
const elem = exclSA ? 'BostadsformanSmahusEjUlagSA' : 'BostadsformanSmahusUlagAG'
|
||||||
|
lines.push(` <gem:${elem} faltkod="${code}">1</gem:${elem}>`)
|
||||||
|
} else if (emp.housingBenefit === 'ej_smahus') {
|
||||||
|
const code = exclSA ? '138' : '043'
|
||||||
|
const elem = exclSA ? 'BostadsformanEjSmahusEjUlagSA' : 'BostadsformanEjSmahusUlagAG'
|
||||||
|
lines.push(` <gem:${elem} faltkod="${code}">1</gem:${elem}>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Övriga skattepliktiga förmåner AMOUNT: FK012 (UlagAG) or FK132 (ej UlagSA).
|
||||||
|
// Includes meals, bike, wellness, "other", and the full housing krona-amount.
|
||||||
if (emp.benefitOther && emp.benefitOther > 0) {
|
if (emp.benefitOther && emp.benefitOther > 0) {
|
||||||
lines.push(` <gem:SkatteplOvrigaFormanerUlagAG faltkod="012">${formatAmount(emp.benefitOther)}</gem:SkatteplOvrigaFormanerUlagAG>`)
|
const code = exclSA ? '132' : '012'
|
||||||
|
const elem = exclSA ? 'SkatteplOvrigaFormanerEjUlagSA' : 'SkatteplOvrigaFormanerUlagAG'
|
||||||
|
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitOther)}</gem:${elem}>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Meal benefit: element name not verified in the component schema yet.
|
|
||||||
// Intentionally omitted until we have an authoritative mapping.
|
|
||||||
void emp.benefitMeals
|
|
||||||
|
|
||||||
// FK131 — Ersättning till mottagare med F-skattsedel (ej underlag SA)
|
// FK131 — Ersättning till mottagare med F-skattsedel (ej underlag SA)
|
||||||
if (emp.fSkattPayment && emp.fSkattPayment > 0) {
|
if (emp.fSkattPayment && emp.fSkattPayment > 0) {
|
||||||
lines.push(` <gem:KontantErsattningEjUlagSA faltkod="131">${formatAmount(emp.fSkattPayment)}</gem:KontantErsattningEjUlagSA>`)
|
lines.push(` <gem:KontantErsattningEjUlagSA faltkod="131">${formatAmount(emp.fSkattPayment)}</gem:KontantErsattningEjUlagSA>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FK048 — FormanHarJusterats (any benefit value adjusted away from schablon)
|
||||||
|
if (emp.benefitsAdjusted) {
|
||||||
|
lines.push(' <gem:FormanHarJusterats faltkod="048">1</gem:FormanHarJusterats>')
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK062 / FK063 — Växa-stöd. Mutually exclusive: FK062 for employees
|
||||||
|
// hired before 2024-05-01 (legacy "första anställda"-reglerna), FK063
|
||||||
|
// for those hired 2024-05-01 and later (utvidgat växa-stöd).
|
||||||
|
if (emp.vaxaStod === 'forsta_anstalld') {
|
||||||
|
lines.push(' <gem:ForstaAnstalld faltkod="062">1</gem:ForstaAnstalld>')
|
||||||
|
} else if (emp.vaxaStod === 'vaxa_stod') {
|
||||||
|
lines.push(' <gem:VaxaStod faltkod="063">1</gem:VaxaStod>')
|
||||||
|
}
|
||||||
|
|
||||||
// Sjuk/VAB/föräldra-dagar flows elsewhere:
|
// Sjuk/VAB/föräldra-dagar flows elsewhere:
|
||||||
// - Per-employee sick days are reported to Försäkringskassan, not AGI.
|
// - Per-employee sick days are reported to Försäkringskassan, not AGI.
|
||||||
// The company-level total goes in HU as TotalSjuklonekostnad (FK499).
|
// The company-level total goes in HU as TotalSjuklonekostnad (FK499).
|
||||||
@@ -377,6 +582,9 @@ export function generateAGIXml(
|
|||||||
if (periodAsNumber >= 202501) {
|
if (periodAsNumber >= 202501) {
|
||||||
for (const emp of employees) {
|
for (const emp of employees) {
|
||||||
if (!emp.absenceEvents || emp.absenceEvents.length === 0) continue
|
if (!emp.absenceEvents || emp.absenceEvents.length === 0) continue
|
||||||
|
// Tombstoned IU: skip absence records too. A removed individuppgift
|
||||||
|
// can't be the parent of frånvarouppgifter for the period.
|
||||||
|
if (emp.removed) continue
|
||||||
|
|
||||||
let pnr: string
|
let pnr: string
|
||||||
try {
|
try {
|
||||||
@@ -386,20 +594,18 @@ export function generateAGIXml(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable specifikationsnummer per (employee, period): sort by date,
|
// Sort by date for stable XML output. The specifikationsnummer
|
||||||
// then 1-based index. Two events on the same date get sequential
|
// itself comes from salary_absence_days.franvaro_specifikationsnummer
|
||||||
// numbers. The unique key in the Skatteverket spec is
|
// (assigned by DB trigger on INSERT and never re-numbered) so
|
||||||
// (BetalningsmottagarId, FranvaroDatum, FranvaroSpecifikationsnummer,
|
// corrections survive day deletions without index shifts.
|
||||||
// RedovisningsPeriod, AgRegistreradId), so within one employee+date
|
|
||||||
// duplicates of the same number replace.
|
|
||||||
const sorted = [...emp.absenceEvents].sort((a, b) => {
|
const sorted = [...emp.absenceEvents].sort((a, b) => {
|
||||||
if (a.date < b.date) return -1
|
if (a.date < b.date) return -1
|
||||||
if (a.date > b.date) return 1
|
if (a.date > b.date) return 1
|
||||||
return 0
|
return a.specifikationsnummer - b.specifikationsnummer
|
||||||
})
|
})
|
||||||
|
|
||||||
sorted.forEach((event, idx) => {
|
sorted.forEach((event) => {
|
||||||
const specNumber = idx + 1
|
const specNumber = event.specifikationsnummer
|
||||||
const isVab = event.type === 'vab'
|
const isVab = event.type === 'vab'
|
||||||
const franvaroTyp = isVab ? 'TILLFALLIG_FORALDRAPENNING' : 'FORALDRAPENNING'
|
const franvaroTyp = isVab ? 'TILLFALLIG_FORALDRAPENNING' : 'FORALDRAPENNING'
|
||||||
const hoursElement = isVab ? 'FranvaroTimmarTFP' : 'FranvaroTimmarFP'
|
const hoursElement = isVab ? 'FranvaroTimmarTFP' : 'FranvaroTimmarFP'
|
||||||
@@ -423,12 +629,23 @@ export function generateAGIXml(
|
|||||||
|
|
||||||
lines.push('</Skatteverket>')
|
lines.push('</Skatteverket>')
|
||||||
|
|
||||||
return lines.join('\n')
|
const xml = lines.join('\n')
|
||||||
|
assertPayloadSize(xml)
|
||||||
|
return xml
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build individuppgifter snapshot for storage in agi_declarations table.
|
* Build individuppgifter snapshot for storage in agi_declarations.individuppgifter
|
||||||
* Used for corrections — must reference same FK570.
|
* (jsonb). Sole purpose: stabilise FK570 (specifikationsnummer) across
|
||||||
|
* corrections by recording the (personnummer → specificationNumber) binding
|
||||||
|
* along with the headline totals that drive a re-issue decision.
|
||||||
|
*
|
||||||
|
* GDPR Art.25 (data minimisation): xml_content is the authoritative record
|
||||||
|
* of what was filed. Storing the full per-benefit breakdown here would
|
||||||
|
* duplicate sensitive financial detail with no incremental audit value, so
|
||||||
|
* detailed benefit fields (car/fuel/housing/meals/other/fSkatt) are
|
||||||
|
* deliberately omitted from the snapshot. Reconstruct them from
|
||||||
|
* xml_content when needed.
|
||||||
*/
|
*/
|
||||||
export function buildIndividuppgifterSnapshot(
|
export function buildIndividuppgifterSnapshot(
|
||||||
employees: AGIEmployeeData[]
|
employees: AGIEmployeeData[]
|
||||||
@@ -443,13 +660,11 @@ export function buildIndividuppgifterSnapshot(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
personnummer: pnr,
|
personnummer: pnr,
|
||||||
fk570: emp.specificationNumber,
|
specificationNumber: emp.specificationNumber,
|
||||||
ruta011: emp.grossSalary,
|
grossSalary: emp.grossSalary,
|
||||||
ruta001: emp.taxWithheld,
|
taxWithheld: emp.taxWithheld,
|
||||||
ruta020: emp.avgifterBasis,
|
avgifterBasis: emp.avgifterBasis,
|
||||||
fk821: emp.sickDays || 0,
|
removed: emp.removed ?? false,
|
||||||
fk822: emp.vabDays || 0,
|
|
||||||
fk823: emp.parentalDays || 0,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Add `removed_from_agi` flag to salary_run_employees so a correction can
|
||||||
|
-- tombstone a previously-submitted individuppgift via Skatteverket FK205
|
||||||
|
-- Borttag. The xml generator emits the IU with only identity fields
|
||||||
|
-- (FK201/FK215/FK570/FK006) plus <Borttag>1</Borttag> when this is set —
|
||||||
|
-- Skatteverket matches on (AgRegistreradId, BetalningsmottagarId,
|
||||||
|
-- RedovisningsPeriod, Specifikationsnummer) and removes the prior IU.
|
||||||
|
--
|
||||||
|
-- Defaults to false. Only meaningful for runs that have an AGI declaration
|
||||||
|
-- already filed for the period — the UI gates the toggle on that state.
|
||||||
|
|
||||||
|
ALTER TABLE salary_run_employees
|
||||||
|
ADD COLUMN removed_from_agi BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN salary_run_employees.removed_from_agi IS
|
||||||
|
'When true, emit this IU as FK205 Borttag in the next AGI XML so Skatteverket removes the prior individuppgift for this employee+period. Use for corrections where an employee should not have been in the run.';
|
||||||
|
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Flag for AGI FK048 FormanHarJusterats — emitted on an IU when a benefit
|
||||||
|
-- value has been adjusted away from the schablon (e.g. car benefit reduced
|
||||||
|
-- because the employee uses the car less than the standard assumption, or
|
||||||
|
-- a housing benefit set below the typical jämförelsehyra). Skatteverket
|
||||||
|
-- expects the flag set whenever any förmånsvärde on the IU has been
|
||||||
|
-- justerat downward — they may follow up with questions.
|
||||||
|
--
|
||||||
|
-- We track it per salary_run_employees row because adjustments are decided
|
||||||
|
-- and recorded at run time, not on the employee master.
|
||||||
|
|
||||||
|
ALTER TABLE salary_run_employees
|
||||||
|
ADD COLUMN benefits_adjusted BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN salary_run_employees.benefits_adjusted IS
|
||||||
|
'When true, the AGI XML emits FK048 FormanHarJusterats=1 on this IU. Set when any förmånsvärde on the row has been adjusted away from the standard schablon.';
|
||||||
|
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
-- Persist FranvaroSpecifikationsnummer per (employee, year-month) for AGI
|
||||||
|
-- Frånvarouppgift stability across corrections.
|
||||||
|
--
|
||||||
|
-- Before this migration the xml generator computed the number as a 1-based
|
||||||
|
-- index over sorted absence dates. Skatteverket's spec key for matching a
|
||||||
|
-- replacement is (BetalningsmottagarId, FranvaroDatum,
|
||||||
|
-- FranvaroSpecifikationsnummer, RedovisningsPeriod, AgRegistreradId). The
|
||||||
|
-- date is in the key so adding new days is safe — but deleting a day mid-
|
||||||
|
-- period would shift every later day's index, causing Skatteverket to see
|
||||||
|
-- one Frånvarouppgift as a *replacement* of a different earlier one
|
||||||
|
-- (because the FranvaroDatum changes too) and the earlier one as orphaned.
|
||||||
|
--
|
||||||
|
-- Assigning the number on INSERT and never re-numbering makes the
|
||||||
|
-- correction path safe: if a day is deleted, its number is freed but
|
||||||
|
-- nothing else moves; if a day is added, it gets max+1. Pre-existing
|
||||||
|
-- vab/parental rows are backfilled in date order.
|
||||||
|
|
||||||
|
ALTER TABLE salary_absence_days
|
||||||
|
ADD COLUMN franvaro_specifikationsnummer INTEGER;
|
||||||
|
|
||||||
|
-- Backfill existing vab/parental rows: sort by absence_date within each
|
||||||
|
-- (employee, YYYYMM) bucket and assign sequential numbers from 1. Older
|
||||||
|
-- rows get lower numbers so the first AGI submission for the period (which
|
||||||
|
-- happens shortly after the month ends) stays consistent with what was
|
||||||
|
-- already filed before this migration.
|
||||||
|
--
|
||||||
|
-- The year-month key is computed as YEAR*100+MONTH (e.g. 202605) rather
|
||||||
|
-- than date_trunc('month', …) because expressions used in unique indexes
|
||||||
|
-- must be IMMUTABLE, and date_trunc(text, date) resolves to the STABLE
|
||||||
|
-- timestamptz overload on PostgreSQL < 14 (and even on newer versions with
|
||||||
|
-- some search_path / overload-resolution combinations). extract(year/month
|
||||||
|
-- from date) is unambiguously IMMUTABLE.
|
||||||
|
WITH numbered AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY employee_id, (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int)
|
||||||
|
ORDER BY absence_date, id
|
||||||
|
) AS rn
|
||||||
|
FROM salary_absence_days
|
||||||
|
WHERE absence_type IN ('vab', 'parental')
|
||||||
|
)
|
||||||
|
UPDATE salary_absence_days sad
|
||||||
|
SET franvaro_specifikationsnummer = numbered.rn
|
||||||
|
FROM numbered
|
||||||
|
WHERE sad.id = numbered.id;
|
||||||
|
|
||||||
|
-- One number per (employee, year-month, type-applicable-row). The partial
|
||||||
|
-- index lets sick/pregnancy/etc rows leave the column NULL.
|
||||||
|
CREATE UNIQUE INDEX idx_salary_absence_days_franvaro_specnum
|
||||||
|
ON salary_absence_days (
|
||||||
|
employee_id,
|
||||||
|
(extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int),
|
||||||
|
franvaro_specifikationsnummer
|
||||||
|
)
|
||||||
|
WHERE franvaro_specifikationsnummer IS NOT NULL;
|
||||||
|
|
||||||
|
-- Trigger: assign max+1 within the (employee, year-month) bucket on INSERT
|
||||||
|
-- when the column is NULL and the type warrants a Frånvarouppgift.
|
||||||
|
-- Skatteverket only reports VAB (TILLFALLIG_FORALDRAPENNING) and parental
|
||||||
|
-- (FORALDRAPENNING) — sick days go to Försäkringskassan via a separate
|
||||||
|
-- channel, and the other types (pregnancy, care_relative, study,
|
||||||
|
-- other_leave) are not reported as Frånvarouppgifter at all.
|
||||||
|
CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.franvaro_specifikationsnummer IS NULL
|
||||||
|
AND NEW.absence_type IN ('vab', 'parental') THEN
|
||||||
|
SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1
|
||||||
|
INTO NEW.franvaro_specifikationsnummer
|
||||||
|
FROM salary_absence_days
|
||||||
|
WHERE employee_id = NEW.employee_id
|
||||||
|
AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int)
|
||||||
|
= (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int)
|
||||||
|
AND franvaro_specifikationsnummer IS NOT NULL;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER salary_absence_days_assign_franvaro_specnum
|
||||||
|
BEFORE INSERT ON salary_absence_days
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.assign_franvaro_specifikationsnummer();
|
||||||
|
|
||||||
|
-- If a row's type later changes from sick→vab/parental, assign a number on
|
||||||
|
-- that UPDATE too (otherwise NULL would emit no Frånvarouppgift). The
|
||||||
|
-- reverse transition (vab→sick) keeps the number — harmless because we
|
||||||
|
-- only emit for vab/parental anyway and re-using the slot is fine.
|
||||||
|
CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer_on_update()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.franvaro_specifikationsnummer IS NULL
|
||||||
|
AND NEW.absence_type IN ('vab', 'parental') THEN
|
||||||
|
SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1
|
||||||
|
INTO NEW.franvaro_specifikationsnummer
|
||||||
|
FROM salary_absence_days
|
||||||
|
WHERE employee_id = NEW.employee_id
|
||||||
|
AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int)
|
||||||
|
= (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int)
|
||||||
|
AND franvaro_specifikationsnummer IS NOT NULL;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER salary_absence_days_assign_franvaro_specnum_update
|
||||||
|
BEFORE UPDATE OF absence_type ON salary_absence_days
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN (NEW.absence_type IN ('vab', 'parental') AND OLD.franvaro_specifikationsnummer IS NULL)
|
||||||
|
EXECUTE FUNCTION public.assign_franvaro_specifikationsnummer_on_update();
|
||||||
|
|
||||||
|
COMMENT ON COLUMN salary_absence_days.franvaro_specifikationsnummer IS
|
||||||
|
'Stable per-(employee, year-month) sequence number for AGI Frånvarouppgift FK822. Assigned on INSERT for vab/parental rows; never re-numbered. Lets corrections survive day deletions without shifting numbers on remaining days.';
|
||||||
|
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- AGI FK041 BostadsformanSmahusUlagAG vs FK043 BostadsformanEjSmahusUlagAG
|
||||||
|
-- distinguishes single-family-home (småhus) housing benefits from any other
|
||||||
|
-- type (lägenhet, hyresrätt, etc.). Both are boolean KRYSS in the XSD — the
|
||||||
|
-- AMOUNT belongs in FK012 SkatteplOvrigaFormanerUlagAG.
|
||||||
|
--
|
||||||
|
-- Housing benefit type is a long-term arrangement (employer provides this
|
||||||
|
-- specific dwelling for the employee), so it lives on the employees table
|
||||||
|
-- rather than per-line-item. NULL means no housing benefit; the column is
|
||||||
|
-- only consulted when the employee has a benefit_housing line item.
|
||||||
|
|
||||||
|
ALTER TABLE employees
|
||||||
|
ADD COLUMN housing_benefit_type TEXT
|
||||||
|
CHECK (housing_benefit_type IS NULL OR housing_benefit_type IN ('smahus', 'ej_smahus'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN employees.housing_benefit_type IS
|
||||||
|
'When the employee receives a housing benefit, distinguishes småhus (FK041) from other dwellings (FK043) in the AGI XML. NULL = no housing benefit. Defaults to ej_smahus interpretation if a benefit_housing line item exists but this column is NULL.';
|
||||||
|
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
-- skatteverket_api_audit_log: long-lived audit trail for every outbound call
|
||||||
|
-- to a Skatteverket regulator endpoint. Separate from audit_log (which is
|
||||||
|
-- DB-trigger-only and lacks company_id/endpoint columns). Type II reviewers
|
||||||
|
-- need to reconstruct who submitted what AGI/moms payload to SKV and when.
|
||||||
|
--
|
||||||
|
-- Append-only. RLS by company_id. No retention TTL — these records are part
|
||||||
|
-- of räkenskapsinformation under BFL 7 kap (7-year minimum).
|
||||||
|
|
||||||
|
CREATE TABLE public.skatteverket_api_audit_log (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT,
|
||||||
|
user_id uuid NOT NULL, -- no cascade: survives user deletion
|
||||||
|
endpoint text NOT NULL, -- e.g. 'agi.kontrollera.hu', 'agi.submit', 'moms.declaration.lock'
|
||||||
|
ag_registered_id text, -- 12-digit IDENTITET (orgnr or pnr) when known
|
||||||
|
redovisningsperiod text, -- YYYYMM when applicable
|
||||||
|
outcome text NOT NULL CHECK (outcome IN ('ok', 'validation_error', 'skv_error', 'auth_error', 'internal_error')),
|
||||||
|
response_status integer, -- HTTP status from SKV (or local error code)
|
||||||
|
skv_status text, -- 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE' for kontrollsvar
|
||||||
|
request_size_bytes integer,
|
||||||
|
correlation_id text, -- for cross-system tracing
|
||||||
|
error_message text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.skatteverket_api_audit_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY skatteverket_api_audit_log_select ON public.skatteverket_api_audit_log
|
||||||
|
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
|
||||||
|
|
||||||
|
-- No INSERT/UPDATE/DELETE policies for normal roles — service role writes
|
||||||
|
-- only. Immutability triggers below block all UPDATE/DELETE regardless of role.
|
||||||
|
|
||||||
|
CREATE INDEX idx_skv_audit_company_created ON public.skatteverket_api_audit_log (company_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_skv_audit_endpoint ON public.skatteverket_api_audit_log (endpoint);
|
||||||
|
CREATE INDEX idx_skv_audit_user ON public.skatteverket_api_audit_log (user_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.skatteverket_audit_immutable()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'Skatteverket audit log entries cannot be modified or deleted';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER skv_audit_no_update
|
||||||
|
BEFORE UPDATE ON public.skatteverket_api_audit_log
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.skatteverket_audit_immutable();
|
||||||
|
|
||||||
|
CREATE TRIGGER skv_audit_no_delete
|
||||||
|
BEFORE DELETE ON public.skatteverket_api_audit_log
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.skatteverket_audit_immutable();
|
||||||
|
|
||||||
|
-- salary_absence_franvaro_audit: SOC 2 CC7.2 — record every assignment of
|
||||||
|
-- franvaro_specifikationsnummer by the DB trigger so a Type II reviewer can
|
||||||
|
-- reconstruct the sequence history per (employee, year-month). The trigger
|
||||||
|
-- runs SECURITY DEFINER (implicit in plpgsql functions that own the table);
|
||||||
|
-- without this, an auditor has no record of when each number was minted.
|
||||||
|
CREATE TABLE public.salary_absence_franvaro_audit (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
absence_day_id uuid NOT NULL,
|
||||||
|
employee_id uuid NOT NULL,
|
||||||
|
absence_date date NOT NULL,
|
||||||
|
year_month text NOT NULL, -- YYYY-MM
|
||||||
|
old_specifikationsnummer integer,
|
||||||
|
new_specifikationsnummer integer NOT NULL,
|
||||||
|
trigger_op text NOT NULL CHECK (trigger_op IN ('insert', 'update')),
|
||||||
|
assigned_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.salary_absence_franvaro_audit ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE INDEX idx_franvaro_audit_employee_month
|
||||||
|
ON public.salary_absence_franvaro_audit (employee_id, year_month, assigned_at);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.franvaro_audit_immutable()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'Frånvaro audit entries cannot be modified or deleted';
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER franvaro_audit_no_update
|
||||||
|
BEFORE UPDATE ON public.salary_absence_franvaro_audit
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.franvaro_audit_immutable();
|
||||||
|
|
||||||
|
CREATE TRIGGER franvaro_audit_no_delete
|
||||||
|
BEFORE DELETE ON public.salary_absence_franvaro_audit
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.franvaro_audit_immutable();
|
||||||
|
|
||||||
|
-- Replace the trigger functions to (1) take an advisory lock per
|
||||||
|
-- (employee_id, year-month) so concurrent inserts serialise rather than
|
||||||
|
-- racing on MAX(...)+1 and surfacing as a unique-violation, and (2) write an
|
||||||
|
-- audit row capturing the assignment.
|
||||||
|
--
|
||||||
|
-- pg_advisory_xact_lock is released at transaction commit/rollback and is
|
||||||
|
-- keyed by a 64-bit int built from the employee UUID's low 32 bits XOR'd
|
||||||
|
-- with the year-month integer. Collisions across employees in different
|
||||||
|
-- months are harmless (just serialise more than needed); collisions across
|
||||||
|
-- different employees in the same month are statistically negligible and
|
||||||
|
-- still correct.
|
||||||
|
CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_lock_key bigint;
|
||||||
|
v_year_month text;
|
||||||
|
v_new_num integer;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.franvaro_specifikationsnummer IS NULL
|
||||||
|
AND NEW.absence_type IN ('vab', 'parental') THEN
|
||||||
|
v_year_month := to_char(NEW.absence_date, 'YYYY-MM');
|
||||||
|
v_lock_key := ('x' || substr(replace(NEW.employee_id::text, '-', ''), 1, 8))::bit(32)::bigint
|
||||||
|
# (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int);
|
||||||
|
PERFORM pg_advisory_xact_lock(v_lock_key);
|
||||||
|
|
||||||
|
SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1
|
||||||
|
INTO v_new_num
|
||||||
|
FROM salary_absence_days
|
||||||
|
WHERE employee_id = NEW.employee_id
|
||||||
|
AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int)
|
||||||
|
= (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int)
|
||||||
|
AND franvaro_specifikationsnummer IS NOT NULL;
|
||||||
|
|
||||||
|
NEW.franvaro_specifikationsnummer := v_new_num;
|
||||||
|
|
||||||
|
INSERT INTO public.salary_absence_franvaro_audit (
|
||||||
|
absence_day_id, employee_id, absence_date, year_month,
|
||||||
|
old_specifikationsnummer, new_specifikationsnummer, trigger_op
|
||||||
|
) VALUES (
|
||||||
|
NEW.id, NEW.employee_id, NEW.absence_date, v_year_month,
|
||||||
|
NULL, v_new_num, 'insert'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer_on_update()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_lock_key bigint;
|
||||||
|
v_year_month text;
|
||||||
|
v_new_num integer;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.franvaro_specifikationsnummer IS NULL
|
||||||
|
AND NEW.absence_type IN ('vab', 'parental') THEN
|
||||||
|
v_year_month := to_char(NEW.absence_date, 'YYYY-MM');
|
||||||
|
v_lock_key := ('x' || substr(replace(NEW.employee_id::text, '-', ''), 1, 8))::bit(32)::bigint
|
||||||
|
# (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int);
|
||||||
|
PERFORM pg_advisory_xact_lock(v_lock_key);
|
||||||
|
|
||||||
|
SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1
|
||||||
|
INTO v_new_num
|
||||||
|
FROM salary_absence_days
|
||||||
|
WHERE employee_id = NEW.employee_id
|
||||||
|
AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int)
|
||||||
|
= (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int)
|
||||||
|
AND franvaro_specifikationsnummer IS NOT NULL;
|
||||||
|
|
||||||
|
NEW.franvaro_specifikationsnummer := v_new_num;
|
||||||
|
|
||||||
|
INSERT INTO public.salary_absence_franvaro_audit (
|
||||||
|
absence_day_id, employee_id, absence_date, year_month,
|
||||||
|
old_specifikationsnummer, new_specifikationsnummer, trigger_op
|
||||||
|
) VALUES (
|
||||||
|
NEW.id, NEW.employee_id, NEW.absence_date, v_year_month,
|
||||||
|
OLD.franvaro_specifikationsnummer, v_new_num, 'update'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
Reference in New Issue
Block a user