fix(sie-import): accept tab as field separator (Bollbok exports) (#513)

* fix(sie-import): accept tab as field separator (Bollbok exports)

The SIE 4 spec allows either space or tab between fields, but
splitSIELine() only treated space (0x20) as a separator. Bollbok
exports tab-separated lines for every record except #RAR, which
silently swallowed all #IB / #UB / #KONTO / #KTYP / #VER / #TRANS
records — imports appeared empty even though the file was well-formed.

Also adds a parser-side diagnostic that emits a warning when raw #IB
or #VER lines are present in the input but parsing produced none. The
previous silent failure is how this bug stayed hidden; the warning
gives the import preview something visible to surface next time.

Verified against two real reproducer files (Sean / Erik Hellqvist):
  erik h 2025.SE (UTF-8): 166 accounts, 66 IB, 4 UB, 11 RES, 95 vouchers, 198 TRANS.
  erik h 2026.SE (CP437): 166 accounts, 66 IB, 4 UB, 0 vouchers.
Both now parse with zero warnings/errors.

Tests:
  + 8 Bollbok-shape tab-separated fixtures (2025 + 2026 quoting variants).
  + 4 silent-failure diagnostic-warning tests.
  All 74 sie-parser tests pass; 155/155 in lib/import; 64/64 downstream callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sie-import): address PR #513 review — strip #KTYP quotes, suppress redundant aggregate warning

Two non-blocking P2 findings from Greptile review on PR #513:

1. #KTYP handler stored fields[2] directly, so Bollbok 2026 exports
   (#KTYP\t1510\t"T") stored '"T"' with literal quotes instead of 'T'.
   Latent defect — accountType is unused downstream today, but my tab-
   separator fix made the quoted-value path reachable. Now routes through
   parseStringField so both Bollbok 2025 (unquoted T) and 2026 (quoted "T")
   land as 'T'.

2. The aggregate "kontrollera fältavskiljare och teckenkodning" warning
   fired alongside per-record 'error'-severity issues for malformed #IB /
   #VER records, producing a misleading hint when the parser had already
   pinpointed the structural problem. Now suppressed when an error-severity
   issue with the same tag already exists.

Test coverage:
  + accountType asserted to be 'T' (not '"T"') in both 2025 + 2026 shapes.
  + VER aggregate-warning test now uses #VER lines without { } blocks
    (silent loss, no per-record error) — the canonical case the diagnostic
    is designed for.
  + New suppression test: bare #VER produces per-record errors AND the
    aggregate warning is absent.

75/75 sie-parser tests pass; 156/156 in lib/import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-17 16:55:17 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 831920fede
commit f829c96d8b
2 changed files with 273 additions and 2 deletions
+39 -2
View File
@@ -322,7 +322,10 @@ function splitSIELine(line: string): string[] {
continue
}
if (char === ' ' && !inQuotes && braceDepth === 0) {
// SIE 4 spec allows either space or tab as field separator (programs like
// Bollbok export tab-separated lines). Quoted strings and brace-bounded
// dimension lists preserve any interior whitespace via the guards above.
if ((char === ' ' || char === '\t') && !inQuotes && braceDepth === 0) {
if (current) {
fields.push(current)
current = ''
@@ -519,8 +522,10 @@ export function parseSIEFile(content: string): ParsedSIEFile {
case 'KTYP': {
// #KTYP accountNumber type
// Bollbok 2025 writes the type unquoted (T), Bollbok 2026 writes it
// quoted ("T"). parseStringField strips the quotes in both cases.
const accountNum = fields[1]
const accountType = fields[2]
const accountType = parseStringField(fields[2])
const account = accounts.find((a) => a.number === accountNum)
if (account) {
account.accountType = accountType
@@ -718,6 +723,38 @@ export function parseSIEFile(content: string): ParsedSIEFile {
addIssue(issues, 'info', 0, `Account ${accountNumber} added from transaction data (not in #KONTO)`)
}
// Silent-failure diagnostic: if the raw input declares #IB / #VER records
// but parsing produced none, surface a warning instead of letting the file
// look empty. Historically a tab-separator or encoding mismatch could swallow
// all balance/voucher records without any visible signal.
//
// Suppressed when per-record 'error' issues already exist for the same tag —
// in that case the parser already pinpointed the root cause (e.g. malformed
// verification definition), so the generic "check separator/encoding" hint
// would be misleading.
const rawIBCount = lines.filter((l) => /^\s*#IB\b/.test(l)).length
const rawVERCount = lines.filter((l) => /^\s*#VER\b/.test(l)).length
const hasIBError = issues.some((i) => i.severity === 'error' && i.tag === 'IB')
const hasVERError = issues.some((i) => i.severity === 'error' && i.tag === 'VER')
if (rawIBCount > 0 && openingBalances.length === 0 && !hasIBError) {
addIssue(
issues,
'warning',
0,
`${rawIBCount} #IB-rader hittades men inga ingående saldon kunde tolkas — kontrollera fältavskiljare och teckenkodning`,
'IB'
)
}
if (rawVERCount > 0 && vouchers.length === 0 && !hasVERError) {
addIssue(
issues,
'warning',
0,
`${rawVERCount} #VER-rader hittades men inga verifikationer kunde tolkas — kontrollera fältavskiljare och teckenkodning`,
'VER'
)
}
// Calculate statistics
const currentFiscalYear = header.fiscalYears.find((fy) => fy.yearIndex === 0)
const totalTransactionLines = vouchers.reduce((sum, v) => sum + v.lines.length, 0)