Files
accounted/lib/reports/ne-bilaga/sru-generator.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

243 lines
10 KiB
TypeScript

import { getBranding } from '@/lib/branding/service'
import type { NEDeclaration, NEDeclarationRutor, SRUSubmission } from '@/lib/reports/ne-bilaga/types'
/**
* SRU File Generator for NE-bilaga (enskild näringsidkare)
*
* Generates a Skatteverket-compliant SRU submission consisting of two files:
* - INFO.SRU: submitter metadata (who is filing)
* - BLANKETTER.SRU: a single NE blankett block with the räkenskapsschema rutor
*
* The NE-bilaga is an appendix to Inkomstdeklaration 1 (INK1) filed by a physical
* person, so the identifier is the owner's PERSONNUMMER (12-digit YYYYMMDDNNNN):
* NOT a juridisk-person org number with the "16" century prefix used by INK2.
*
* Encoding: ISO 8859-1 (applied by the API route via encodeISO88591).
* Line endings: CRLF. Amounts: integers in hela kronor (öre truncated per SFL 22:1).
*
* Field codes (Fältkod -> Rad NE) are taken from BAS-kontogruppen's official
* coupling table "NE - Inkomst av näringsverksamhet, Enskilda näringsidkare"
* (bas.se/kontoplaner/sru/). Confirmed against the BAS NE_EJ_K1 kopplingstabell:
* R1 7400 · R2 7401 · R3 7402 · R4 7403 · R5 7500 · R6 7501 · R7 7502 ·
* R8 7503 · R9 7504 · R10 7505 · R11 7440. Period dates: 7011 (start) / 7012 (end).
*/
const CRLF = '\r\n'
const PROGRAM_VERSION = '1.0'
/** Räkenskapsårets start-/slutdatum (standard period date fält, shared across blanketter). */
const FISCAL_START_CODE = '7011'
const FISCAL_END_CODE = '7012'
/** Authoritative NE-bilaga räkenskapsschema field codes (BAS kopplingstabell NE_EJ_K1). */
const NE_SRU_FIELD_CODES: Record<keyof NEDeclarationRutor, string> = {
R1: '7400', // Försäljning och utfört arbete samt övriga momspliktiga intäkter
R2: '7401', // Momsfria intäkter
R3: '7402', // Bil- och bostadsförmån m.m.
R4: '7403', // Ränteintäkter m.m.
R5: '7500', // Varor och legoarbeten
R6: '7501', // Övriga externa kostnader
R7: '7502', // Anställd personal
R8: '7503', // Räntekostnader m.m.
R9: '7504', // Avskrivningar och nedskrivningar byggnader och markanläggningar
R10: '7505', // Avskrivningar och nedskrivningar maskiner/inventarier/immateriella tillgångar
R11: '7440', // Bokfört resultat
}
/**
* Compute the period suffix for the blankett type string, from the month the
* fiscal year ENDS in. Enskild firma is almost always calendar-year (-> P4).
* P1 = Jan-Apr, P2 = May-Aug, P4 = Sep-Dec. P3 (short first year) is handled manually.
*/
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'
return 'P4'
}
/** The income year (inkomstår) is the year the fiscal year ends. */
function getIncomeYear(fiscalYearEnd: string): string {
return fiscalYearEnd.substring(0, 4)
}
/**
* Normalize an enskild firma identity (personnummer) to 12 digits YYYYMMDDNNNN.
* Unlike INK2's juridisk-person formatter, this does NOT prepend "16": for a
* physical person the century is the birth century.
*
* For a 10-digit number (YYMMDDNNNN) the century is inferred from age: a NE-bilaga
* filer is an adult, so we pick the century that yields a plausible adult age
* (≥18, <110) at the income year, preferring 1900s. This avoids mapping e.g. a
* 1924-born filer for income year 2024 (yy=24) to 2024. (Skatteverket's '-'/'+'
* century separator is lost once non-digits are stripped, so age is used instead.)
*
* Returns the all-zero placeholder for missing/unexpected input; callers validate
* the result and surface a generation error rather than shipping an invalid file.
*/
function formatIdentityNumber12(raw: string | null, incomeYear: number): string {
const digits = (raw || '').replace(/\D/g, '')
if (digits.length === 12) return digits
if (digits.length === 10) {
const yy = parseInt(digits.substring(0, 2), 10)
const ageIf2000s = incomeYear - (2000 + yy)
const century = ageIf2000s >= 18 && ageIf2000s < 110 ? '20' : '19'
return `${century}${digits}`
}
return '000000000000'
}
/** 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}`
}
/** Convert a YYYY-MM-DD string to SRU date format YYYYMMDD. */
function dateStringToSRU(dateStr: string): string {
return dateStr.replace(/-/g, '')
}
/** Format an integer amount: hela kronor, no decimals/thousands separators, öre truncated. */
function formatAmount(amount: number): string {
return Math.trunc(amount).toString()
}
/** Sanitize string for SRU: '#' is reserved, strip newlines, cap at 250 chars (STR_250). */
function sanitizeString(str: string): string {
return str.replace(/#/g, '').replace(/[\r\n]/g, ' ').substring(0, 250)
}
/** Generate the INFO.SRU file content (submitter metadata). */
function generateInfoSru(declaration: NEDeclaration, now: Date, identity12: string): string {
const lines: string[] = []
// 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 (mandatory: ORGNR, NAMN, POSTNR, POSTORT)
lines.push('#MEDIELEV_START')
lines.push(`#ORGNR ${identity12}`)
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').replace(/\s/g, '')}`)
lines.push(`#POSTORT ${sanitizeString(declaration.companyInfo.city || 'Okänd')}`)
if (declaration.companyInfo.email) {
lines.push(`#EMAIL ${sanitizeString(declaration.companyInfo.email)}`)
}
lines.push('#MEDIELEV_SLUT')
return lines.join(CRLF) + CRLF
}
/** Generate the BLANKETTER.SRU file content (a single NE blankett block). */
function generateBlanketterSru(declaration: NEDeclaration, now: Date, identity12: string): string {
const lines: string[] = []
const incomeYearStr = getIncomeYear(declaration.fiscalYear.end)
const periodSuffix = computePeriodSuffix(declaration.fiscalYear.end)
const taxpayerName = sanitizeString(declaration.companyInfo.companyName)
lines.push(`#BLANKETT NE-${incomeYearStr}${periodSuffix}`)
lines.push(`#IDENTITET ${identity12} ${formatDate(now)} ${formatTime(now)}`)
lines.push(`#NAMN ${taxpayerName}`)
// Räkenskapsårets datum
lines.push(`#UPPGIFT ${FISCAL_START_CODE} ${dateStringToSRU(declaration.fiscalYear.start)}`)
lines.push(`#UPPGIFT ${FISCAL_END_CODE} ${dateStringToSRU(declaration.fiscalYear.end)}`)
// NE rutor R1-R11: emit non-zero values only (zero/empty fields must be omitted)
const rutaOrder: (keyof NEDeclarationRutor)[] = [
'R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9', 'R10', 'R11',
]
for (const ruta of rutaOrder) {
const value = declaration.rutor[ruta]
if (value !== 0) {
lines.push(`#UPPGIFT ${NE_SRU_FIELD_CODES[ruta]} ${formatAmount(value)}`)
}
}
lines.push('#BLANKETTSLUT')
lines.push('#FIL_SLUT')
return lines.join(CRLF) + CRLF
}
/** Generate a complete SRU submission (INFO.SRU + BLANKETTER.SRU) for the NE-bilaga. */
export function generateNESRUSubmission(declaration: NEDeclaration): SRUSubmission {
const now = new Date()
const incomeYear = parseInt(getIncomeYear(declaration.fiscalYear.end), 10)
const identity12 = formatIdentityNumber12(declaration.companyInfo.orgNumber, incomeYear)
// A valid NE filing requires the owner's personnummer. Refuse rather than ship a
// structurally well-formed file with a placeholder #IDENTITET that Skatteverket
// would reject at upload: surface it as a generation error the route can show.
if (!/^\d{12}$/.test(identity12) || identity12 === '000000000000') {
throw new Error(
'NE-bilagan kräver ett giltigt personnummer (ÅÅÅÅMMDDNNNN) för den enskilda näringsidkaren. ' +
'Komplettera personnumret i företagsinställningarna innan du laddar ner SRU-filen.',
)
}
return {
infoSru: generateInfoSru(declaration, now, identity12),
blanketterSru: generateBlanketterSru(declaration, now, identity12),
generatedAt: now.toISOString(),
}
}
/** Validate the generated BLANKETTER.SRU content for the mandatory NE structure. */
export function validateBlanketterSru(content: string): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
if (!/^#BLANKETT NE-/m.test(content)) errors.push('Missing #BLANKETT NE- block')
if (!/^#IDENTITET /m.test(content)) errors.push('Missing #IDENTITET')
if (!/^#NAMN /m.test(content)) errors.push('Missing #NAMN')
// Räkenskapsårets datum are mandatory for income declarations; their absence is
// a level-2 rejection at Skatteverket, so catch it in the pre-flight.
if (!new RegExp(`^#UPPGIFT ${FISCAL_START_CODE} `, 'm').test(content)) {
errors.push(`Missing #UPPGIFT ${FISCAL_START_CODE} (räkenskapsårets början)`)
}
if (!new RegExp(`^#UPPGIFT ${FISCAL_END_CODE} `, 'm').test(content)) {
errors.push(`Missing #UPPGIFT ${FISCAL_END_CODE} (räkenskapsårets slut)`)
}
if (!/^#FIL_SLUT/m.test(content)) errors.push('Missing #FIL_SLUT terminator')
const blankettslutCount = (content.match(/^#BLANKETTSLUT/gm) || []).length
if (blankettslutCount !== 1) {
errors.push(`Expected 1 #BLANKETTSLUT, found ${blankettslutCount}`)
}
return {
isValid: errors.length === 0,
errors,
}
}
/** Get the ZIP filename for download. Uses the income year (fiscal year END) so the
* filename matches the blankett type/identity for broken fiscal years. */
export function getZipFilename(declaration: NEDeclaration): string {
const year = getIncomeYear(declaration.fiscalYear.end)
const orgNumber = declaration.companyInfo.orgNumber?.replace(/\D/g, '') || 'unknown'
return `NE_SRU_${orgNumber}_${year}.zip`
}