fix(sie): add ?encoding=cp437 for legacy bookkeeping software (#810)

* 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 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

* 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 <noreply@anthropic.com>

Signed-off-by: Jonas Flodén <jonas@floden.nu>

---------

Signed-off-by: Jonas Flodén <jonas@floden.nu>
This commit is contained in:
Jonas Flodén
2026-06-29 22:59:55 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 37ee125b9b
commit 837f354d81
5 changed files with 52 additions and 16 deletions
+8 -3
View File
@@ -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,
},
@@ -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,
},
+7 -7
View File
@@ -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 () => {
+24 -1
View File
@@ -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<number, number> = {
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"`)
+2
View File
@@ -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