Files
accounted/lib/reports/ink2/sru-generator.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

300 lines
9.2 KiB
TypeScript

import { getBranding } from '@/lib/branding/service'
import type {
INK2Declaration,
INK2RSRUCode,
INK2SRutor,
SRUSubmission,
} from './types'
import {
INK2R_ASSET_CODES,
INK2R_EQUITY_LIABILITY_CODES,
INK2R_INCOME_CODES,
} from './types'
/**
* SRU File Generator for INK2 (Aktiebolag)
*
* Generates a Skatteverket-compliant SRU submission consisting of:
* - INFO.SRU: submitter metadata
* - BLANKETTER.SRU: three blankett blocks (INK2, INK2R, INK2S)
*
* Encoding: ISO 8859-1 (handled by the API route when writing the response)
* Line endings: CRLF
* Amounts: integers in hela kronor, no decimals, no thousands separators
* Org number: 12 digits with century prefix 16 for juridisk person
*/
const CRLF = '\r\n'
const PROGRAM_VERSION = '1.0'
/**
* Compute the period suffix for blankett type strings.
* Based on which month the fiscal year ENDS in:
* P1 = Jan-Apr, P2 = May-Aug, P3 = special, P4 = Sep-Dec
*/
function computePeriodSuffix(fiscalYearEnd: string): string {
const endMonth = parseInt(fiscalYearEnd.substring(5, 7), 10)
if (endMonth >= 1 && endMonth <= 4) return 'P1'
if (endMonth >= 5 && endMonth <= 8) return 'P2'
// P4 covers Sep-Dec (most common: calendar year companies)
// NOTE: P3 (first/short fiscal year) cannot be derived from end month alone.
// Callers must handle P3 manually for brutet räkenskapsår.
return 'P4'
}
/**
* Get the income year from the fiscal year end date.
* The year in the blankett type string is the income year.
*/
function getIncomeYear(fiscalYearEnd: string): string {
return fiscalYearEnd.substring(0, 4)
}
/**
* Format org number as 12-digit with century prefix.
* Swedish juridiska personer use century prefix "16".
* Input: "556677-8899" or "5566778899"
* Output: "165566778899"
*/
function formatOrgNumber12(orgNumber: string): string {
const clean = orgNumber.replace(/-/g, '')
if (clean.length === 12) return clean
if (clean.length === 10) return `16${clean}`
return `16${clean}`
}
/**
* Format a Date as YYYYMMDD
*/
function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
/**
* Format a Date as HHMMSS
*/
function formatTime(date: Date): string {
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
const s = String(date.getSeconds()).padStart(2, '0')
return `${h}${m}${s}`
}
/**
* Format integer amount for SRU. No decimals, no thousands separator.
* Truncated to hela kronor by the engine.
*/
function formatAmount(amount: number): string {
return Math.trunc(amount).toString()
}
/**
* Generate the INFO.SRU file content
*/
function generateInfoSru(declaration: INK2Declaration, now: Date): string {
const lines: string[] = []
const orgNumber12 = declaration.companyInfo.orgNumber
? formatOrgNumber12(declaration.companyInfo.orgNumber)
: '000000000000'
// DATABESKRIVNING block (required order)
lines.push('#DATABESKRIVNING_START')
lines.push('#PRODUKT SRU')
lines.push(`#SKAPAD ${formatDate(now)} ${formatTime(now)}`)
lines.push(`#PROGRAM ${sanitizeString(getBranding().appName.toLowerCase())} ${PROGRAM_VERSION}`)
lines.push('#FILNAMN BLANKETTER.SRU')
lines.push('#DATABESKRIVNING_SLUT')
// MEDIELEV block
lines.push('#MEDIELEV_START')
lines.push(`#ORGNR ${orgNumber12}`)
lines.push(`#NAMN ${sanitizeString(declaration.companyInfo.companyName)}`)
if (declaration.companyInfo.addressLine1) {
lines.push(`#ADRESS ${sanitizeString(declaration.companyInfo.addressLine1)}`)
}
lines.push(`#POSTNR ${declaration.companyInfo.postalCode || '00000'}`)
lines.push(`#POSTORT ${sanitizeString(declaration.companyInfo.city || 'Okänd')}`)
if (declaration.companyInfo.email) {
lines.push(`#EMAIL ${declaration.companyInfo.email}`)
}
lines.push('#MEDIELEV_SLUT')
return lines.join(CRLF) + CRLF
}
/**
* Generate the BLANKETTER.SRU file content with three blankett blocks
*/
function generateBlanketterSru(declaration: INK2Declaration, now: Date): string {
const lines: string[] = []
const orgNumber12 = declaration.companyInfo.orgNumber
? formatOrgNumber12(declaration.companyInfo.orgNumber)
: '000000000000'
const incomeYear = getIncomeYear(declaration.fiscalYear.end)
const periodSuffix = computePeriodSuffix(declaration.fiscalYear.end)
const companyName = sanitizeString(declaration.companyInfo.companyName)
const dateStr = formatDate(now)
// Each blankett gets a unique timestamp (increment seconds)
const time0 = formatTime(now)
const time1 = formatTime(new Date(now.getTime() + 1000))
const time2 = formatTime(new Date(now.getTime() + 2000))
// ---- Block 1: INK2 (huvudblankett) ----
lines.push(`#BLANKETT INK2-${incomeYear}${periodSuffix}`)
lines.push(`#IDENTITET ${orgNumber12} ${dateStr} ${time0}`)
lines.push(`#NAMN ${companyName}`)
// Fiscal year dates
lines.push(`#UPPGIFT 7011 ${declaration.ink2['7011']}`)
lines.push(`#UPPGIFT 7012 ${declaration.ink2['7012']}`)
// Överskott/underskott
if (declaration.ink2['7113'] > 0) {
lines.push(`#UPPGIFT 7113 ${formatAmount(declaration.ink2['7113'])}`)
}
if (declaration.ink2['7114'] > 0) {
lines.push(`#UPPGIFT 7114 ${formatAmount(declaration.ink2['7114'])}`)
}
lines.push('#BLANKETTSLUT')
// ---- Block 2: INK2R (räkenskapsschema) ----
lines.push(`#BLANKETT INK2R-${incomeYear}${periodSuffix}`)
lines.push(`#IDENTITET ${orgNumber12} ${dateStr} ${time1}`)
lines.push(`#NAMN ${companyName}`)
// Fiscal year dates
lines.push(`#UPPGIFT 7011 ${declaration.ink2['7011']}`)
lines.push(`#UPPGIFT 7012 ${declaration.ink2['7012']}`)
// All INK2R fields in canonical Skatteverket order: emit non-zero values only
const ink2rCodes: INK2RSRUCode[] = [
...INK2R_ASSET_CODES,
...INK2R_EQUITY_LIABILITY_CODES,
...INK2R_INCOME_CODES,
]
for (const code of ink2rCodes) {
const value = declaration.ink2r[code]
if (value !== 0) {
lines.push(`#UPPGIFT ${code} ${formatAmount(value)}`)
}
}
lines.push('#BLANKETTSLUT')
// ---- Block 3: INK2S (skattemässiga justeringar) ----
lines.push(`#BLANKETT INK2S-${incomeYear}${periodSuffix}`)
lines.push(`#IDENTITET ${orgNumber12} ${dateStr} ${time2}`)
lines.push(`#NAMN ${companyName}`)
// Fiscal year dates
lines.push(`#UPPGIFT 7011 ${declaration.ink2s['7011']}`)
lines.push(`#UPPGIFT 7012 ${declaration.ink2s['7012']}`)
// INK2S numeric fields: emit non-zero values only
const ink2sNumericFields: (keyof INK2SRutor)[] = [
'7650',
'7750',
'7651',
'7653',
'7754',
'8020',
'8021',
]
for (const code of ink2sNumericFields) {
const value = declaration.ink2s[code]
if (typeof value === 'number' && value !== 0) {
lines.push(`#UPPGIFT ${code} ${formatAmount(value)}`)
}
}
lines.push('#BLANKETTSLUT')
// Required terminator
lines.push('#FIL_SLUT')
return lines.join(CRLF) + CRLF
}
/**
* Sanitize string for SRU: remove # characters (reserved), limit to 250 chars
*/
function sanitizeString(str: string): string {
return str.replace(/#/g, '').replace(/[\r\n]/g, ' ').substring(0, 250)
}
/**
* Generate complete SRU submission (INFO.SRU + BLANKETTER.SRU)
*/
export function generateSRUSubmission(declaration: INK2Declaration): SRUSubmission {
const now = new Date()
return {
infoSru: generateInfoSru(declaration, now),
blanketterSru: generateBlanketterSru(declaration, now),
generatedAt: now.toISOString(),
}
}
/**
* Validate the generated BLANKETTER.SRU content
*/
export function validateBlanketterSru(content: string): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
// Check for required blankett blocks
const hasINK2 = /^#BLANKETT INK2-/m.test(content)
const hasINK2R = /^#BLANKETT INK2R-/m.test(content)
const hasINK2S = /^#BLANKETT INK2S-/m.test(content)
const hasFilSlut = /^#FIL_SLUT/m.test(content)
if (!hasINK2) errors.push('Missing INK2 blankett block')
if (!hasINK2R) errors.push('Missing INK2R blankett block')
if (!hasINK2S) errors.push('Missing INK2S blankett block')
if (!hasFilSlut) errors.push('Missing #FIL_SLUT terminator')
// Count BLANKETTSLUT: should be exactly 3
const blankettslutCount = (content.match(/^#BLANKETTSLUT/gm) || []).length
if (blankettslutCount !== 3) {
errors.push(`Expected 3 BLANKETTSLUT, found ${blankettslutCount}`)
}
// Check that each blankett has #IDENTITET
const blankettBlocks = content.split(/^#BLANKETT /m).slice(1)
for (const block of blankettBlocks) {
if (!block.includes('#IDENTITET')) {
const type = block.split('\n')[0]?.split('\r')[0] || 'unknown'
errors.push(`Blankett ${type} missing #IDENTITET`)
}
if (!block.includes('#NAMN')) {
const type = block.split('\n')[0]?.split('\r')[0] || 'unknown'
errors.push(`Blankett ${type} missing #NAMN`)
}
}
return {
isValid: errors.length === 0,
errors,
}
}
/**
* Get ZIP filename for download
*/
export function getZipFilename(declaration: INK2Declaration): string {
const year = declaration.fiscalYear.start.substring(0, 4)
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
return `INK2_SRU_${orgNumber}_${year}.zip`
}