* 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f8504f3bd0
commit
46039f14f4
@@ -6,6 +6,7 @@ import {
|
||||
} from '@/lib/reports/ink2/sru-generator'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { encodeISO88591 } from '@/lib/reports/sru-encoding'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
/**
|
||||
@@ -67,13 +68,3 @@ export const GET = withRouteContext(
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Encode a string as ISO 8859-1 bytes; characters outside Latin-1 become '?'. */
|
||||
function encodeISO88591(str: string): Uint8Array {
|
||||
const bytes = new Uint8Array(str.length)
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i)
|
||||
bytes[i] = code <= 0xFF ? code : 0x3F
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateNEDeclaration } from '@/lib/reports/ne-bilaga/ne-engine'
|
||||
import {
|
||||
generateSRUFile,
|
||||
sruFileToString,
|
||||
getSRUFilename,
|
||||
generateNESRUSubmission,
|
||||
getZipFilename,
|
||||
} from '@/lib/reports/ne-bilaga/sru-generator'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { encodeISO88591 } from '@/lib/reports/sru-encoding'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
/**
|
||||
* GET /api/reports/ne-bilaga
|
||||
@@ -34,14 +35,23 @@ export const GET = withRouteContext(
|
||||
const declaration = await generateNEDeclaration(supabase, companyId!, periodId)
|
||||
|
||||
if (format === 'sru') {
|
||||
const sruFile = generateSRUFile(declaration)
|
||||
const sruContent = sruFileToString(sruFile)
|
||||
const filename = getSRUFilename(declaration)
|
||||
const submission = generateNESRUSubmission(declaration)
|
||||
|
||||
return new NextResponse(sruContent, {
|
||||
// Skatteverket requires ISO 8859-1 (Latin-1); UTF-8 mojibakes å/ä/ö and is rejected.
|
||||
const infoBytes = encodeISO88591(submission.infoSru)
|
||||
const blanketterBytes = encodeISO88591(submission.blanketterSru)
|
||||
|
||||
const zip = new JSZip()
|
||||
zip.file('INFO.SRU', infoBytes)
|
||||
zip.file('BLANKETTER.SRU', blanketterBytes)
|
||||
|
||||
const zipArrayBuffer = await zip.generateAsync({ type: 'arraybuffer' })
|
||||
const filename = getZipFilename(declaration)
|
||||
|
||||
return new NextResponse(zipArrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'X-Request-Id': requestId,
|
||||
},
|
||||
|
||||
@@ -361,11 +361,11 @@ export function EfDeclarationSection({
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/api/reports/ne-bilaga?fiscal_period_id=${fiscalPeriodId}`}
|
||||
href={`/api/reports/ne-bilaga?period_id=${fiscalPeriodId}&format=sru`}
|
||||
prefetch={false}
|
||||
>
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
Förhandsgranska NE-bilaga
|
||||
Ladda ner NE-bilaga (SRU)
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { formatCurrency } from '@/lib/utils'
|
||||
export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<NEDeclaration | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchDeclaration = async () => {
|
||||
@@ -32,8 +33,24 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const downloadSRU = () => {
|
||||
window.open(`/api/reports/ne-bilaga?period_id=${periodId}&format=sru`, '_blank')
|
||||
const downloadSRU = async () => {
|
||||
setDownloading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}&format=sru`)
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const filename = res.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] || 'NE_SRU.zip'
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
setError('Kunde inte ladda ner SRU-filer')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// NE ruta labels
|
||||
@@ -72,9 +89,9 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
|
||||
</Button>
|
||||
{data && (
|
||||
<Button variant="outline" onClick={downloadSRU}>
|
||||
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Ladda ner SRU-fil
|
||||
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-fil'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
generateNESRUSubmission,
|
||||
validateBlanketterSru,
|
||||
getZipFilename,
|
||||
} from '../sru-generator'
|
||||
import { encodeISO88591 } from '@/lib/reports/sru-encoding'
|
||||
import type { NEDeclaration, NEDeclarationRutor } from '../types'
|
||||
|
||||
function makeDeclaration(opts: {
|
||||
rutor?: Partial<NEDeclarationRutor>
|
||||
companyInfo?: Partial<NEDeclaration['companyInfo']>
|
||||
fiscalYear?: Partial<NEDeclaration['fiscalYear']>
|
||||
} = {}): NEDeclaration {
|
||||
const rutor: NEDeclarationRutor = {
|
||||
R1: 500000, R2: 0, R3: 0, R4: 1200,
|
||||
R5: 120000, R6: 80000, R7: 0, R8: 3000,
|
||||
R9: 0, R10: 20000, R11: 198200,
|
||||
...opts.rutor,
|
||||
}
|
||||
const breakdown = Object.fromEntries(
|
||||
(Object.keys(rutor) as (keyof NEDeclarationRutor)[]).map((k) => [
|
||||
k,
|
||||
{ accounts: [] as { accountNumber: string; accountName: string; amount: number }[], total: rutor[k] },
|
||||
])
|
||||
) as NEDeclaration['breakdown']
|
||||
|
||||
return {
|
||||
fiscalYear: {
|
||||
id: 'fp-1',
|
||||
name: 'Räkenskapsår 2024',
|
||||
start: '2024-01-01',
|
||||
end: '2024-12-31',
|
||||
isClosed: true,
|
||||
...opts.fiscalYear,
|
||||
},
|
||||
rutor,
|
||||
breakdown,
|
||||
companyInfo: {
|
||||
companyName: 'Östgöta Träförädling',
|
||||
orgNumber: '199001019802',
|
||||
addressLine1: 'Storgatan 1',
|
||||
postalCode: '111 22',
|
||||
city: 'Stockholm',
|
||||
email: 'agare@example.se',
|
||||
...opts.companyInfo,
|
||||
},
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('NE-bilaga SRU generator', () => {
|
||||
describe('INFO.SRU', () => {
|
||||
it('declares #PRODUKT SRU (never the KU code KONTROLLUPPGIFTER)', () => {
|
||||
const { infoSru } = generateNESRUSubmission(makeDeclaration())
|
||||
expect(infoSru).toContain('#PRODUKT SRU')
|
||||
expect(infoSru).not.toContain('KONTROLLUPPGIFTER')
|
||||
})
|
||||
|
||||
it('uses the valid #SKAPAD post (not the legacy #SKAPAT typo)', () => {
|
||||
const { infoSru } = generateNESRUSubmission(makeDeclaration())
|
||||
expect(infoSru).toMatch(/#SKAPAD \d{8} \d{6}/)
|
||||
expect(infoSru).not.toContain('#SKAPAT')
|
||||
})
|
||||
|
||||
it('has the DATABESKRIVNING + MEDIELEV blocks with mandatory posts', () => {
|
||||
const { infoSru } = generateNESRUSubmission(makeDeclaration())
|
||||
expect(infoSru).toContain('#DATABESKRIVNING_START')
|
||||
expect(infoSru).toContain('#FILNAMN BLANKETTER.SRU')
|
||||
expect(infoSru).toContain('#DATABESKRIVNING_SLUT')
|
||||
expect(infoSru).toContain('#MEDIELEV_START')
|
||||
expect(infoSru).toContain('#ORGNR 199001019802')
|
||||
expect(infoSru).toContain('#NAMN Östgöta Träförädling')
|
||||
expect(infoSru).toContain('#POSTNR 11122')
|
||||
expect(infoSru).toContain('#POSTORT Stockholm')
|
||||
expect(infoSru).toContain('#MEDIELEV_SLUT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BLANKETTER.SRU', () => {
|
||||
it('emits a single NE blankett block terminated by #FIL_SLUT', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
|
||||
expect(blanketterSru).toMatch(/#BLANKETT NE-2024P4/)
|
||||
expect(blanketterSru).toMatch(/#IDENTITET 199001019802 \d{8} \d{6}/)
|
||||
expect(blanketterSru).toContain('#NAMN Östgöta Träförädling')
|
||||
expect(blanketterSru).toContain('#BLANKETTSLUT')
|
||||
expect(blanketterSru).toContain('#FIL_SLUT')
|
||||
})
|
||||
|
||||
it('emits the fiscal-year date fields 7011/7012', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7011 20240101')
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7012 20241231')
|
||||
})
|
||||
|
||||
it('maps each ruta to the authoritative BAS field code', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7400 500000') // R1
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7403 1200') // R4
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7500 120000') // R5
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7501 80000') // R6
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7503 3000') // R8
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7505 20000') // R10
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7440 198200') // R11
|
||||
})
|
||||
|
||||
it('does not use the old (wrong) 73xx field codes', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
|
||||
expect(blanketterSru).not.toContain('7310')
|
||||
expect(blanketterSru).not.toContain('7350')
|
||||
expect(blanketterSru).not.toContain('#UPPGIFT 7000')
|
||||
})
|
||||
|
||||
it('omits #UPPGIFT lines for zero-value rutor', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
|
||||
// R2/R3/R7/R9 are 0 in the fixture
|
||||
expect(blanketterSru).not.toContain('#UPPGIFT 7401')
|
||||
expect(blanketterSru).not.toContain('#UPPGIFT 7402')
|
||||
expect(blanketterSru).not.toContain('#UPPGIFT 7502')
|
||||
expect(blanketterSru).not.toContain('#UPPGIFT 7504')
|
||||
})
|
||||
|
||||
it('renders a negative result (loss) with a minus sign', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration({ rutor: { R11: -5000 } }))
|
||||
expect(blanketterSru).toContain('#UPPGIFT 7440 -5000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('identity normalization (enskild firma personnummer)', () => {
|
||||
it('passes a 12-digit personnummer through unchanged (no "16" prefix)', () => {
|
||||
const { infoSru } = generateNESRUSubmission(
|
||||
makeDeclaration({ companyInfo: { orgNumber: '19900101-9802' } })
|
||||
)
|
||||
expect(infoSru).toContain('#ORGNR 199001019802')
|
||||
expect(infoSru).not.toContain('#ORGNR 16')
|
||||
})
|
||||
|
||||
it('expands a 10-digit personnummer to 12 digits with a birth century', () => {
|
||||
const { infoSru } = generateNESRUSubmission(
|
||||
makeDeclaration({ companyInfo: { orgNumber: '900101-9802' } })
|
||||
)
|
||||
expect(infoSru).toContain('#ORGNR 199001019802')
|
||||
})
|
||||
|
||||
it('infers 1900s for a 10-digit yy that would map to a child in the 2000s', () => {
|
||||
// yy=24 for income year 2024: naive yy<=24 → 2024 (age 0); adult-age logic must pick 1924.
|
||||
const { infoSru } = generateNESRUSubmission(
|
||||
makeDeclaration({ companyInfo: { orgNumber: '2401019808' } })
|
||||
)
|
||||
expect(infoSru).toContain('#ORGNR 192401019808')
|
||||
})
|
||||
})
|
||||
|
||||
describe('identity validation (no silent placeholder file)', () => {
|
||||
it('throws when the personnummer is missing', () => {
|
||||
expect(() =>
|
||||
generateNESRUSubmission(makeDeclaration({ companyInfo: { orgNumber: null } }))
|
||||
).toThrow(/personnummer/i)
|
||||
})
|
||||
|
||||
it('throws when the identity has an unexpected length', () => {
|
||||
expect(() =>
|
||||
generateNESRUSubmission(makeDeclaration({ companyInfo: { orgNumber: '12345' } }))
|
||||
).toThrow(/personnummer/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateBlanketterSru', () => {
|
||||
it('passes on generated output', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
|
||||
const result = validateBlanketterSru(blanketterSru)
|
||||
expect(result.isValid).toBe(true)
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
|
||||
it('fails when #FIL_SLUT is missing', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
|
||||
const result = validateBlanketterSru(blanketterSru.replace('#FIL_SLUT', ''))
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errors).toContain('Missing #FIL_SLUT terminator')
|
||||
})
|
||||
|
||||
it('fails when the mandatory fiscal-year date field (7011) is missing', () => {
|
||||
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
|
||||
const result = validateBlanketterSru(blanketterSru.replace(/#UPPGIFT 7011 \d+\r?\n/, ''))
|
||||
expect(result.isValid).toBe(false)
|
||||
expect(result.errors.some((e) => e.includes('7011'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ISO 8859-1 encoding', () => {
|
||||
it('encodes å/ä/ö as Latin-1 bytes, not UTF-8 and not "?"', () => {
|
||||
const { infoSru } = generateNESRUSubmission(makeDeclaration())
|
||||
const bytes = encodeISO88591(infoSru)
|
||||
// Östgöta → Ö=0xD6, ä not here; check å(0xE5) ä(0xE4) ö(0xF6) present for "Östgöta"
|
||||
expect(Array.from(bytes)).toContain(0xd6) // Ö
|
||||
expect(Array.from(bytes)).toContain(0xf6) // ö
|
||||
// No replacement char and no UTF-8 lead byte 0xC3 for these chars
|
||||
expect(Array.from(bytes)).not.toContain(0x3f) // '?'
|
||||
expect(Array.from(bytes)).not.toContain(0xc3) // UTF-8 lead byte
|
||||
})
|
||||
})
|
||||
|
||||
describe('getZipFilename', () => {
|
||||
it('produces NE_SRU_<id>_<year>.zip', () => {
|
||||
expect(getZipFilename(makeDeclaration())).toBe('NE_SRU_199001019802_2024.zip')
|
||||
})
|
||||
|
||||
it('uses the income year (fiscal year end) for a broken fiscal year', () => {
|
||||
expect(
|
||||
getZipFilename(makeDeclaration({ fiscalYear: { start: '2024-05-01', end: '2025-04-30' } }))
|
||||
).toBe('NE_SRU_199001019802_2025.zip')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -175,7 +175,7 @@ export async function generateNEDeclaration(
|
||||
// Fetch company settings
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, entity_type')
|
||||
.select('company_name, org_number, entity_type, address_line1, postal_code, city, email')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
@@ -337,6 +337,10 @@ export async function generateNEDeclaration(
|
||||
companyInfo: {
|
||||
companyName: settings?.company_name || 'Okänt företag',
|
||||
orgNumber: settings?.org_number || null,
|
||||
addressLine1: settings?.address_line1 || null,
|
||||
postalCode: settings?.postal_code || null,
|
||||
city: settings?.city || null,
|
||||
email: settings?.email || null,
|
||||
},
|
||||
warnings,
|
||||
}
|
||||
|
||||
@@ -1,201 +1,230 @@
|
||||
import type { NEDeclaration, SRUFile, SRURecord } from '@/lib/reports/ne-bilaga/types'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
|
||||
function sanitizeString(str: string): string {
|
||||
return str.replace(/#/g, '').replace(/[\r\n]/g, ' ').substring(0, 250)
|
||||
}
|
||||
import type { NEDeclaration, NEDeclarationRutor, SRUSubmission } from '@/lib/reports/ne-bilaga/types'
|
||||
|
||||
/**
|
||||
* SRU File Generator
|
||||
* SRU File Generator for NE-bilaga (enskild näringsidkare)
|
||||
*
|
||||
* Generates SRU (Standardiserat Räkenskapsutdrag) files for electronic
|
||||
* submission to Skatteverket. The SRU format is used for tax declarations.
|
||||
* 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
|
||||
*
|
||||
* Format specification:
|
||||
* - Each line starts with # followed by field code
|
||||
* - Values follow the field code
|
||||
* - File must be plain text (ISO-8859-1 encoding traditionally, but UTF-8 is often accepted)
|
||||
* 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.
|
||||
*
|
||||
* NE-bilaga field codes:
|
||||
* - #BLANKETT NE - Declares this is an NE form
|
||||
* - #IDENTITET - Organization number + name
|
||||
* - #UPPGIFT - Individual field values
|
||||
* 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).
|
||||
*/
|
||||
|
||||
/**
|
||||
* SRU field codes for NE declaration
|
||||
* These are the official Skatteverket field codes for NE-bilaga
|
||||
*/
|
||||
const NE_SRU_FIELD_CODES: Record<string, string> = {
|
||||
// Company identification
|
||||
ORG_NUMBER: '201',
|
||||
COMPANY_NAME: '202',
|
||||
const CRLF = '\r\n'
|
||||
const PROGRAM_VERSION = '1.0'
|
||||
|
||||
// NE rutor - Income
|
||||
R1: '7310', // Försäljning och andra intäkter med moms
|
||||
R2: '7311', // Momsfria intäkter (ej skattepliktiga)
|
||||
R3: '7312', // Bil- och bostadsförmån m.m.
|
||||
R4: '7313', // Ränteintäkter
|
||||
/** Räkenskapsårets start-/slutdatum (standard period date fält, shared across blanketter). */
|
||||
const FISCAL_START_CODE = '7011'
|
||||
const FISCAL_END_CODE = '7012'
|
||||
|
||||
// NE rutor - Expenses
|
||||
R5: '7320', // Varuinköp
|
||||
R6: '7321', // Övriga externa kostnader
|
||||
R7: '7322', // Anställdas löner
|
||||
R8: '7323', // Räntekostnader
|
||||
R9: '7324', // Avskrivningar på byggnader och markanläggningar
|
||||
R10: '7325', // Avskrivningar på maskiner och inventarier
|
||||
|
||||
// Result
|
||||
R11: '7350', // Årets resultat
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SRU file content from NE declaration
|
||||
* 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.
|
||||
*/
|
||||
export function generateSRUFile(declaration: NEDeclaration): SRUFile {
|
||||
const records: SRURecord[] = []
|
||||
const now = new Date()
|
||||
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'
|
||||
}
|
||||
|
||||
// File header
|
||||
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
|
||||
records.push({ fieldCode: 'SESSION', value: '1' })
|
||||
records.push({ fieldCode: 'PROGRAMNAMN', value: sanitizeString(getBranding().appName) })
|
||||
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
|
||||
records.push({
|
||||
fieldCode: 'SKAPAT',
|
||||
value: formatSRUDate(now),
|
||||
})
|
||||
|
||||
// Form declaration
|
||||
records.push({ fieldCode: 'BLANKETT', value: 'NE' })
|
||||
|
||||
// Company identification
|
||||
if (declaration.companyInfo.orgNumber) {
|
||||
// Remove any dashes from org number
|
||||
const cleanOrgNumber = declaration.companyInfo.orgNumber.replace(/-/g, '')
|
||||
records.push({
|
||||
fieldCode: 'IDENTITET',
|
||||
value: cleanOrgNumber,
|
||||
})
|
||||
}
|
||||
|
||||
// Fiscal year
|
||||
records.push({
|
||||
fieldCode: 'UPPGIFT',
|
||||
value: `7000 ${formatSRUDateRange(declaration.fiscalYear.start, declaration.fiscalYear.end)}`,
|
||||
})
|
||||
|
||||
// NE rutor values
|
||||
const rutaEntries: [keyof typeof NE_SRU_FIELD_CODES, number][] = [
|
||||
['R1', declaration.rutor.R1],
|
||||
['R2', declaration.rutor.R2],
|
||||
['R3', declaration.rutor.R3],
|
||||
['R4', declaration.rutor.R4],
|
||||
['R5', declaration.rutor.R5],
|
||||
['R6', declaration.rutor.R6],
|
||||
['R7', declaration.rutor.R7],
|
||||
['R8', declaration.rutor.R8],
|
||||
['R9', declaration.rutor.R9],
|
||||
['R10', declaration.rutor.R10],
|
||||
['R11', declaration.rutor.R11],
|
||||
]
|
||||
|
||||
for (const [ruta, value] of rutaEntries) {
|
||||
// Only include non-zero values
|
||||
if (value !== 0) {
|
||||
const fieldCode = NE_SRU_FIELD_CODES[ruta]
|
||||
records.push({
|
||||
fieldCode: 'UPPGIFT',
|
||||
value: `${fieldCode} ${formatSRUAmount(value)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// End of form
|
||||
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
|
||||
|
||||
return {
|
||||
records,
|
||||
generatedAt: now.toISOString(),
|
||||
}
|
||||
/** The income year (inkomstår) is the year the fiscal year ends. */
|
||||
function getIncomeYear(fiscalYearEnd: string): string {
|
||||
return fiscalYearEnd.substring(0, 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SRU file to string content
|
||||
* 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.
|
||||
*/
|
||||
export function sruFileToString(sruFile: SRUFile): string {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const record of sruFile.records) {
|
||||
if (record.value === '') {
|
||||
lines.push(`#${record.fieldCode}`)
|
||||
} else {
|
||||
lines.push(`#${record.fieldCode} ${record.value}`)
|
||||
}
|
||||
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}`
|
||||
}
|
||||
|
||||
// SRU files should end with a newline
|
||||
return lines.join('\r\n') + '\r\n'
|
||||
return '000000000000'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for SRU: YYYYMMDD
|
||||
*/
|
||||
function formatSRUDate(date: Date): string {
|
||||
/** 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 date string (YYYY-MM-DD) to SRU format (YYYYMMDD)
|
||||
*/
|
||||
/** 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 fiscal year date range for SRU
|
||||
*/
|
||||
function formatSRUDateRange(startDate: string, endDate: string): string {
|
||||
return `${dateStringToSRU(startDate)}-${dateStringToSRU(endDate)}`
|
||||
/** Format an integer amount: hela kronor, no decimals/thousands separators, öre truncated. */
|
||||
function formatAmount(amount: number): string {
|
||||
return Math.trunc(amount).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format amount for SRU
|
||||
* - Whole numbers (no decimals)
|
||||
* - No thousands separator
|
||||
* - Negative values with minus sign
|
||||
*/
|
||||
function formatSRUAmount(amount: number): string {
|
||||
return Math.round(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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SRU file content
|
||||
*/
|
||||
export function validateSRUFile(sruFile: SRUFile): {
|
||||
/** 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[] = []
|
||||
|
||||
// Check for required records
|
||||
const hasHeader = sruFile.records.some(r => r.fieldCode === 'PRODUKT')
|
||||
const hasBlankett = sruFile.records.some(r => r.fieldCode === 'BLANKETT')
|
||||
const hasBlankettslut = sruFile.records.some(r => r.fieldCode === 'BLANKETTSLUT')
|
||||
|
||||
if (!hasHeader) {
|
||||
errors.push('Missing PRODUKT header')
|
||||
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 (!hasBlankett) {
|
||||
errors.push('Missing BLANKETT declaration')
|
||||
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')
|
||||
|
||||
if (!hasBlankettslut) {
|
||||
errors.push('Missing BLANKETTSLUT')
|
||||
const blankettslutCount = (content.match(/^#BLANKETTSLUT/gm) || []).length
|
||||
if (blankettslutCount !== 1) {
|
||||
errors.push(`Expected 1 #BLANKETTSLUT, found ${blankettslutCount}`)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -204,11 +233,10 @@ export function validateSRUFile(sruFile: SRUFile): {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename for SRU file download
|
||||
*/
|
||||
export function getSRUFilename(declaration: NEDeclaration): string {
|
||||
const year = declaration.fiscalYear.start.substring(0, 4)
|
||||
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
|
||||
return `NE_${orgNumber}_${year}.sru`
|
||||
/** 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`
|
||||
}
|
||||
|
||||
@@ -44,23 +44,23 @@ export interface NEDeclaration {
|
||||
}>
|
||||
total: number
|
||||
}>
|
||||
// Company info for SRU
|
||||
// Company info for SRU (orgNumber for enskild firma is the owner's personnummer)
|
||||
companyInfo: {
|
||||
companyName: string
|
||||
orgNumber: string | null
|
||||
addressLine1: string | null
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
email: string | null
|
||||
}
|
||||
// Warnings
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
// SRU file format types
|
||||
export interface SRURecord {
|
||||
fieldCode: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
export interface SRUFile {
|
||||
records: SRURecord[]
|
||||
// A complete SRU submission: two files (INFO.SRU + BLANKETTER.SRU), ISO 8859-1 encoded by the route.
|
||||
export interface SRUSubmission {
|
||||
infoSru: string
|
||||
blanketterSru: string
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shared encoding helpers for Skatteverket SRU files.
|
||||
*
|
||||
* SRU submissions (INFO.SRU + BLANKETTER.SRU) must be ISO 8859-1 (Latin-1),
|
||||
* never UTF-8 — Swedish characters (å, ä, ö) corrupt otherwise and Skatteverkets
|
||||
* filöverföringstjänst rejects the upload. This is the single most common cause
|
||||
* of programmatic SRU validation failure.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a string as ISO 8859-1 (Latin-1) bytes.
|
||||
* Characters outside the Latin-1 range (> 0xFF) are replaced with '?' (0x3F).
|
||||
*/
|
||||
export function encodeISO88591(str: string): Uint8Array {
|
||||
const bytes = new Uint8Array(str.length)
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i)
|
||||
bytes[i] = code <= 0xff ? code : 0x3f
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
Reference in New Issue
Block a user