diff --git a/app/api/reports/ink2/route.ts b/app/api/reports/ink2/route.ts index 08556d29..d056aabf 100644 --- a/app/api/reports/ink2/route.ts +++ b/app/api/reports/ink2/route.ts @@ -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 -} diff --git a/app/api/reports/ne-bilaga/route.ts b/app/api/reports/ne-bilaga/route.ts index 15331b33..67981b65 100644 --- a/app/api/reports/ne-bilaga/route.ts +++ b/app/api/reports/ne-bilaga/route.ts @@ -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, }, diff --git a/components/bookkeeping/year-end/EfDeclarationSection.tsx b/components/bookkeeping/year-end/EfDeclarationSection.tsx index 90f77db0..38dac11e 100644 --- a/components/bookkeeping/year-end/EfDeclarationSection.tsx +++ b/components/bookkeeping/year-end/EfDeclarationSection.tsx @@ -361,11 +361,11 @@ export function EfDeclarationSection({ diff --git a/components/reports/NEDeclarationView.tsx b/components/reports/NEDeclarationView.tsx index 53e7b822..1c03f894 100644 --- a/components/reports/NEDeclarationView.tsx +++ b/components/reports/NEDeclarationView.tsx @@ -12,6 +12,7 @@ import { formatCurrency } from '@/lib/utils' export function NEDeclarationView({ periodId }: { periodId: string }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) + const [downloading, setDownloading] = useState(false) const [error, setError] = useState(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'} {data && ( - )} diff --git a/lib/reports/ne-bilaga/__tests__/sru-generator.test.ts b/lib/reports/ne-bilaga/__tests__/sru-generator.test.ts new file mode 100644 index 00000000..d67edc81 --- /dev/null +++ b/lib/reports/ne-bilaga/__tests__/sru-generator.test.ts @@ -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 + companyInfo?: Partial + fiscalYear?: Partial +} = {}): 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__.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') + }) + }) +}) diff --git a/lib/reports/ne-bilaga/ne-engine.ts b/lib/reports/ne-bilaga/ne-engine.ts index 60ab4a6c..1e725978 100644 --- a/lib/reports/ne-bilaga/ne-engine.ts +++ b/lib/reports/ne-bilaga/ne-engine.ts @@ -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, } diff --git a/lib/reports/ne-bilaga/sru-generator.ts b/lib/reports/ne-bilaga/sru-generator.ts index 5793cdb2..7bd624b0 100644 --- a/lib/reports/ne-bilaga/sru-generator.ts +++ b/lib/reports/ne-bilaga/sru-generator.ts @@ -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 = { - // 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 = { + 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` } diff --git a/lib/reports/ne-bilaga/types.ts b/lib/reports/ne-bilaga/types.ts index b4bc9c26..e11ee80a 100644 --- a/lib/reports/ne-bilaga/types.ts +++ b/lib/reports/ne-bilaga/types.ts @@ -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 } diff --git a/lib/reports/sru-encoding.ts b/lib/reports/sru-encoding.ts new file mode 100644 index 00000000..5ca6c5e8 --- /dev/null +++ b/lib/reports/sru-encoding.ts @@ -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 +}