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:
co-authored by
Claude Sonnet 4.6
parent
37ee125b9b
commit
837f354d81
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"`)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user