* fix(reports): generate valid two-file NE-bilaga SRU submission (#318, #319) The NE-bilaga "Ladda ner SRU" export produced a file Skatteverket rejects: it was served as UTF-8 text/plain (å/ä/ö mojibake, #319) and was structurally invalid — a single blob with #PRODUKT KONTROLLUPPGIFTER (the KU code), no INFO.SRU/BLANKETTER.SRU split, a #SKAPAT typo, no #FIL_SLUT, and suspect field codes 7310–7350 (#318). Rewrite the generator to mirror the working INK2 generator: a two-file INFO.SRU + BLANKETTER.SRU submission, ISO 8859-1 encoded and zipped, with #PRODUKT SRU, #DATABESKRIVNING_*/#MEDIELEV_*, #BLANKETT NE-<år>P<x>, #IDENTITET <personnummer12> <date> <time>, and #FIL_SLUT. Field codes use the authoritative BAS NE_EJ_K1 coupling table (R1→7400 … R10→7505, R11→7440; period dates 7011/7012). Enskild-firma identity is the owner's 12-digit personnummer (birth-century prefix, not INK2's juridisk-person "16"). - Extract the shared ISO-8859-1 encoder to lib/reports/sru-encoding.ts (was inline in the INK2 route). - Extend the NE engine/types to carry address/postort/email for INFO.SRU. - Frontend: NE SRU download uses the INK2 blob pattern; fix a pre-existing param bug in EfDeclarationSection (fiscal_period_id → period_id, +format=sru). - Add generator tests (structure, BAS field codes, zero-omission, ISO-8859-1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): address review feedback on NE-bilaga SRU generator (#318) - getZipFilename uses the income year (fiscal year END) so the filename matches the blankett type/identity for broken fiscal years. - Refuse to generate a submission when the personnummer is missing/invalid (compute + validate the 12-digit identity once in generateNESRUSubmission and throw) instead of silently emitting a placeholder #IDENTITET that Skatteverket would reject after upload. - validateBlanketterSru now asserts the mandatory räkenskapsår date fields (#UPPGIFT 7011/7012) — their absence is a level-2 rejection. - 10-digit personnummer century is inferred from adult age (≥18, <110) at the income year, fixing the e.g. 1924-born/yy=24 edge that mapped to 2024. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
243 lines
10 KiB
TypeScript
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`
|
|
}
|