From 837f354d81e3880586953297b17224f9c5e9eba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Flod=C3=A9n?= Date: Mon, 29 Jun 2026 22:59:55 +0200 Subject: [PATCH] fix(sie): add ?encoding=cp437 for legacy bookkeeping software (#810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sie): add ?encoding=cp437 option for legacy bookkeeping software SIE spec mandates CP437 (#FORMAT PC8) but accounted generates UTF-8. Most modern cloud tools (Fortnox, Bokio) accept UTF-8 fine, so UTF-8 remains the default. Pass ?encoding=cp437 to get a properly encoded CP437 binary with #FORMAT PC8 in the header, required by desktop software such as Visma Administration and BL Administration. Removes the spurious #FORMAT PC8 tag from the default UTF-8 output since declaring CP437 while serving UTF-8 caused mojibake on import. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Jonas Flodén * fix(sie): wrap Uint8Array in Buffer.from so NextResponse accepts it Uint8Array is not directly assignable to BodyInit in the Next.js NextResponse constructor — wrapping with Buffer.from() satisfies the type without changing the byte content. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Jonas Flodén --------- Signed-off-by: Jonas Flodén --- app/api/reports/sie-export/route.ts | 11 +++++--- .../[companyId]/reports/sie-export/route.ts | 16 ++++++++---- lib/reports/__tests__/sie-export.test.ts | 14 +++++------ lib/reports/sie-export.ts | 25 ++++++++++++++++++- types/index.ts | 2 ++ 5 files changed, 52 insertions(+), 16 deletions(-) diff --git a/app/api/reports/sie-export/route.ts b/app/api/reports/sie-export/route.ts index e1a69ee9..c81f0f57 100644 --- a/app/api/reports/sie-export/route.ts +++ b/app/api/reports/sie-export/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server' -import { generateSIEExport } from '@/lib/reports/sie-export' +import { generateSIEExport, encodeSIEToCP437 } from '@/lib/reports/sie-export' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -11,6 +11,7 @@ export const GET = withRouteContext( const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') const excludeClosing = searchParams.get('exclude_closing') === 'true' + const useCP437 = searchParams.get('encoding') === 'cp437' if (!periodId) { return errorResponseFromCode('REPORT_PERIOD_REQUIRED', log, { requestId }) @@ -34,12 +35,16 @@ export const GET = withRouteContext( company_name: company.company_name || 'Unknown', org_number: company.org_number, exclude_year_end_closing: excludeClosing, + emit_format_pc8: useCP437, }) - return new NextResponse(sieContent, { + const body = useCP437 ? Buffer.from(encodeSIEToCP437(sieContent)) : sieContent + const contentType = useCP437 ? 'application/octet-stream' : 'text/plain; charset=utf-8' + + return new NextResponse(body, { status: 200, headers: { - 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="export_${periodId}.se"`, 'X-Request-Id': requestId, }, diff --git a/app/api/v1/companies/[companyId]/reports/sie-export/route.ts b/app/api/v1/companies/[companyId]/reports/sie-export/route.ts index 6f09ecd0..29558117 100644 --- a/app/api/v1/companies/[companyId]/reports/sie-export/route.ts +++ b/app/api/v1/companies/[companyId]/reports/sie-export/route.ts @@ -12,7 +12,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode, v1ErrorResponse } from '@/lib/api/v1/errors' import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period' -import { generateSIEExport } from '@/lib/reports/sie-export' +import { generateSIEExport, encodeSIEToCP437 } from '@/lib/reports/sie-export' registerEndpoint({ operation: 'reports.sie-export', @@ -28,7 +28,7 @@ registerEndpoint({ pitfalls: [ '`period_id` is required.', 'The response is text/plain with Content-Disposition: attachment — clients should treat as a binary download. Filename uses the pattern `export_{period_id}.se`.', - 'Encoding is UTF-8 (modern systems accept it; some legacy Swedish bookkeeping software still expects CP437/Latin-1 — convert client-side if needed).', + 'Default encoding is UTF-8 (no #FORMAT PC8 tag). Pass `encoding=cp437` to get a spec-compliant CP437-encoded file with #FORMAT PC8, required by some legacy desktop bookkeeping software.', 'Only `posted` entries are exported; drafts and reversed entries\' originals are included but marked accordingly.', ], example: { @@ -53,7 +53,9 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( }) if (!period.ok) return period.response - const excludeClosing = new URL(request.url).searchParams.get('exclude_closing') === 'true' + const { searchParams } = new URL(request.url) + const excludeClosing = searchParams.get('exclude_closing') === 'true' + const useCP437 = searchParams.get('encoding') === 'cp437' const { data: company, error: companyErr } = await ctx.supabase .from('company_settings') @@ -74,6 +76,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( company_name: (company as { company_name: string | null }).company_name || 'Unknown', org_number: (company as { org_number: string | null }).org_number, exclude_year_end_closing: excludeClosing, + emit_format_pc8: useCP437, }), { log: ctx.log, requestId: ctx.requestId, reportName: 'sie-export' }, ) @@ -85,10 +88,13 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( // is belt-and-suspenders. const safeId = period.period.id.replace(/[^0-9a-fA-F-]/g, '') - return new NextResponse(gen.result, { + const body = useCP437 ? Buffer.from(encodeSIEToCP437(gen.result)) : gen.result + const contentType = useCP437 ? 'application/octet-stream' : 'text/plain; charset=utf-8' + + return new NextResponse(body, { status: 200, headers: { - 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Type': contentType, 'Content-Disposition': `attachment; filename="export_${safeId}.se"`, 'X-Request-Id': ctx.requestId, }, diff --git a/lib/reports/__tests__/sie-export.test.ts b/lib/reports/__tests__/sie-export.test.ts index 04f5f25e..f4f42c88 100644 --- a/lib/reports/__tests__/sie-export.test.ts +++ b/lib/reports/__tests__/sie-export.test.ts @@ -83,13 +83,13 @@ describe('generateSIEExport', () => { const lines = output.split('\r\n') expect(lines[0]).toBe('#FLAGGA 0') - expect(lines[1]).toBe('#FORMAT PC8') - expect(lines[2]).toBe('#SIETYP 4') - expect(lines[3]).toMatch(/^#PROGRAM "ERPBase" "1\.0"$/) - expect(lines[4]).toMatch(/^#GEN \d{8}$/) - expect(lines[5]).toBe('#ORGNR 556677-8899') - expect(lines[6]).toBe('#FNAMN "Test AB"') - expect(lines[7]).toBe('#RAR 0 20240101 20241231') + expect(lines[1]).toBe('#SIETYP 4') + expect(lines[2]).toMatch(/^#PROGRAM "ERPBase" "1\.0"$/) + expect(lines[3]).toMatch(/^#GEN \d{8}$/) + expect(lines[4]).toBe('#ORGNR 556677-8899') + expect(lines[5]).toBe('#FNAMN "Test AB"') + expect(lines[6]).toBe('#RAR 0 20240101 20241231') + expect(output).not.toContain('#FORMAT PC8') }) it('omits #ORGNR when org_number is null', async () => { diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index 59aa367b..6f7fae38 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -17,6 +17,29 @@ function sanitizeProgramName(str: string): string { * Format: CP437 encoded text file (we'll use UTF-8 as modern systems accept it) * Line format: #TAG field1 field2 ... */ +// Unicode codepoint → CP437 byte for characters used in Swedish accounting data. +// Covers all six Swedish vowel variants plus common Western European accented letters. +const CP437: Record = { + 0x00C7: 0x80, 0x00FC: 0x81, 0x00E9: 0x82, 0x00E2: 0x83, + 0x00E4: 0x84, 0x00E0: 0x85, 0x00E5: 0x86, 0x00E7: 0x87, + 0x00EA: 0x88, 0x00EB: 0x89, 0x00E8: 0x8A, 0x00EF: 0x8B, + 0x00EE: 0x8C, 0x00EC: 0x8D, 0x00C4: 0x8E, 0x00C5: 0x8F, + 0x00C9: 0x90, 0x00E6: 0x91, 0x00C6: 0x92, 0x00F4: 0x93, + 0x00F6: 0x94, 0x00F2: 0x95, 0x00FB: 0x96, 0x00F9: 0x97, + 0x00FF: 0x98, 0x00D6: 0x99, 0x00DC: 0x9A, 0x00A2: 0x9B, + 0x00A3: 0x9C, 0x00A5: 0x9D, 0x00E1: 0xA0, 0x00ED: 0xA1, + 0x00F3: 0xA2, 0x00FA: 0xA3, 0x00F1: 0xA4, 0x00D1: 0xA5, +} + +export function encodeSIEToCP437(text: string): Uint8Array { + const bytes: number[] = [] + for (const char of text) { + const cp = char.codePointAt(0)! + bytes.push(cp < 0x80 ? cp : (CP437[cp] ?? 0x3F)) + } + return new Uint8Array(bytes) +} + export async function generateSIEExport( supabase: SupabaseClient, companyId: string, @@ -139,7 +162,7 @@ export async function generateSIEExport( // === Header === lines.push('#FLAGGA 0') - lines.push('#FORMAT PC8') + if (options.emit_format_pc8) lines.push('#FORMAT PC8') lines.push('#SIETYP 4') const programName = sanitizeProgramName(options.program_name || getBranding().appName) lines.push(`#PROGRAM "${programName}" "1.0"`) diff --git a/types/index.ts b/types/index.ts index 24d29ecb..a289c8d0 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1680,6 +1680,8 @@ export interface SIEExportOptions { * our closing entry would zero out the P&L accounts. */ exclude_year_end_closing?: boolean + /** Emit #FORMAT PC8 in the header. Set true when the caller will encode the output as CP437. */ + emit_format_pc8?: boolean } // Input types for creating entries