Files
accounted/lib/reports/ink2/sru-generator.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

273 lines
8.6 KiB
TypeScript

import { sruAmount as formatAmount, sruDate as formatDate, sruTime as formatTime } from '@/lib/reports/sru/format'
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}`
}
/**
* 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['7104'] > 0) {
lines.push(`#UPPGIFT 7104 ${formatAmount(declaration.ink2['7104'])}`)
}
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`
}