fix(reports): always emit compulsory #FORMAT PC8 in SIE export (#1466)
#FORMAT is a compulsory record in every SIE type and PC8 is its only legal value. We only emitted it when the caller opted into cp437 byte encoding, so the default UTF-8 download had no #FORMAT line and strict importers (Visma Spiris) rejected the file with 'Etiketten #FORMAT saknas i filen'. Cloud exporters (Fortnox, Bokio) ship UTF-8 bytes with #FORMAT PC8 and importers detect the real encoding from the bytes, so the tag is now unconditional. Also formats #ORGNR as nnnnnn-nnnn per spec; company_settings stores the org number without a hyphen. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -834,3 +834,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-08] Per-voucher RC basis gaps (findRcBasisGaps) downgrade from filing-blocking ERROR to WARNING only under per-rate evidence: the 44xx/45xx basis accounts grouped by momssats must match ruta30/0.25, ruta31/0.12, ruta32/0.06 two-sided within 0.5 kr, all rate boxes non-negative, and no RC_OUTPUT_MISSING present. A first cross-rate-sum predicate was refuted by /skeptic (wrong-rate fiktiv moms reached parity and unblocked a 7 800 kr under-declaration; a negative rate box made the predicate vacuous), so the certificate is per-rate, which rutor alone cannot express (rutor 20-24 are partitioned by purchase type, not rate); evidence therefore flows from the account totals. Why downgrade at all: a moms-only rattelseverifikat carries fiktiv moms whose basbelopp lives in another (often reversed) verifikat, and no voucher arrangement satisfies both the per-voucher scan and the aggregate identity in that state, so the ERROR was an unfixable dead end (Orto Engineering 3DJake case 2026-08; support vouchers A169/A175/A177 joined the blocklist they were meant to clear). Data side repaired separately with voucher A177 restoring bank parity and the basis/moms identity exactly.
|
||||
[2026-08-08] Fenced-JSON fix uses brace-slice, not fence-regex: also rescues preamble/postamble prose around the object, and degrades to the existing empty-result path when no braces exist.
|
||||
[2026-08-08] extractJsonObject upgraded from brace-slice to depth-aware balanced scan after PR 1460 review: prose containing braces around the JSON no longer poisons the slice; first parseable candidate wins.
|
||||
[2026-08-08] SIE export always emits #FORMAT PC8 even when bytes are UTF-8: the record is compulsory in the spec and strict importers (Visma Spiris) reject files without it, while real encoding is detected from bytes (Fortnox ships the same shape). Default bytes stay UTF-8; encoding=cp437 remains opt-in.
|
||||
|
||||
@@ -36,7 +36,6 @@ export const GET = withRouteContext(
|
||||
company_name: company.company_name || 'Unknown',
|
||||
org_number: company.org_number,
|
||||
exclude_year_end_closing: excludeClosing,
|
||||
emit_format_pc8: useCP437,
|
||||
})
|
||||
|
||||
const body = useCP437 ? Buffer.from(encodeSIEToCP437(sieContent)) : sieContent
|
||||
|
||||
@@ -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`.',
|
||||
'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.',
|
||||
'The compulsory #FORMAT PC8 tag is always present, but default byte encoding is UTF-8 (the de-facto cloud convention; importers detect encoding from the bytes). Pass `encoding=cp437` for actual CP437 bytes, required by some legacy desktop bookkeeping software.',
|
||||
'Only `posted` entries are exported; drafts and reversed entries\' originals are included but marked accordingly.',
|
||||
],
|
||||
example: {
|
||||
@@ -76,7 +76,6 @@ 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' },
|
||||
)
|
||||
|
||||
@@ -97,13 +97,32 @@ describe('generateSIEExport', () => {
|
||||
const lines = output.split('\r\n')
|
||||
|
||||
expect(lines[0]).toBe('#FLAGGA 0')
|
||||
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')
|
||||
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')
|
||||
})
|
||||
|
||||
it('hyphenates a bare 10-digit org_number in #ORGNR', async () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries (no entries -> line fetch skipped)
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // opening balances RPC
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', {
|
||||
...baseOptions,
|
||||
org_number: '5566778899',
|
||||
})
|
||||
|
||||
expect(output).toContain('#ORGNR 556677-8899')
|
||||
})
|
||||
|
||||
it('omits #ORGNR when org_number is null', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { fetchLinesByEntryIds } from '@/lib/bookkeeping/entry-lines'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { formatOrgNumber } from '@/lib/utils'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { getOpeningBalances } from './opening-balances'
|
||||
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
@@ -158,14 +159,19 @@ export async function generateSIEExport(
|
||||
|
||||
// === Header ===
|
||||
lines.push('#FLAGGA 0')
|
||||
if (options.emit_format_pc8) lines.push('#FORMAT PC8')
|
||||
// #FORMAT is compulsory in every SIE type and PC8 is its only legal value.
|
||||
// Strict importers (e.g. Visma Spiris) reject files without it, and they
|
||||
// detect the actual byte encoding themselves, so it is emitted even when
|
||||
// the output is served as UTF-8.
|
||||
lines.push('#FORMAT PC8')
|
||||
lines.push('#SIETYP 4')
|
||||
const programName = sanitizeProgramName(options.program_name || getBranding().appName)
|
||||
lines.push(`#PROGRAM "${programName}" "1.0"`)
|
||||
lines.push(`#GEN ${formatSIEDate(now)}`)
|
||||
|
||||
if (options.org_number) {
|
||||
lines.push(`#ORGNR ${options.org_number}`)
|
||||
// Spec format is nnnnnn-nnnn; company_settings may store it without the hyphen.
|
||||
lines.push(`#ORGNR ${formatOrgNumber(options.org_number)}`)
|
||||
}
|
||||
|
||||
lines.push(`#FNAMN "${escapeQuotes(options.company_name)}"`)
|
||||
|
||||
@@ -2141,8 +2141,6 @@ 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